Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899e3bce43 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -25,7 +25,10 @@ __pycache__/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
downloads/*
|
||||
!downloads/usbipd-win/
|
||||
downloads/usbipd-win/*
|
||||
!downloads/usbipd-win/usbipd-win-5.3.0.msi
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
|
||||
1
TODO.md
1
TODO.md
@@ -4,6 +4,7 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
|
||||
|
||||
## Earth
|
||||
|
||||
- [ ] Motion Agent v2 hardening: tune the implemented MediaPipe gesture recognizer across camera placements, exercise the UE command/control client, run reconnect and dual-camera soak tests, and continue the v3 calibrated 3D roadmap described in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md).
|
||||
- [ ] Earth AI command entry: merge natural-language and speech-triggered LLM commands into the existing Earth search panel as described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
|
||||
- [ ] Earth action executor: implement safe visualization actions for layer toggles, batch highlights, filters, focus, result panels, and clear-highlight behavior.
|
||||
- [ ] Earth entity matching: support stable entity ids and batch matching for Beidou satellites, mainland China compute centers, BGP, news, vessels, and cables.
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select, text
|
||||
@@ -27,6 +27,20 @@ from app.services.earth_news import (
|
||||
save_earth_news_sources_payload,
|
||||
test_news_source_config,
|
||||
)
|
||||
from app.services.earth_news_manual import (
|
||||
broadcast_manual_news_changed,
|
||||
create_manual_news_group,
|
||||
delete_manual_news_item,
|
||||
get_news_record_or_404,
|
||||
import_manual_news_items,
|
||||
list_news_groups,
|
||||
list_news_records,
|
||||
parse_manual_news_import_upload,
|
||||
rename_manual_news_group,
|
||||
reprocess_manual_news_item,
|
||||
serialize_news_record,
|
||||
upsert_manual_news_item,
|
||||
)
|
||||
from app.services.earth_boundaries import (
|
||||
EarthBoundaryBuildError,
|
||||
get_boundary_build_status,
|
||||
@@ -119,6 +133,26 @@ class EarthNewsSourceTestPayload(BaseModel):
|
||||
source: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EarthNewsManualItemPayload(BaseModel):
|
||||
title: str = Field(default="", max_length=500)
|
||||
summary: str = Field(default="", max_length=1200)
|
||||
content: str = Field(default="", max_length=12000)
|
||||
url: str = Field(default="", max_length=2000)
|
||||
source: str = Field(default="", max_length=255)
|
||||
region: str = Field(default="global", max_length=80)
|
||||
published_at: str | None = None
|
||||
category: str = Field(default="other", max_length=80)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
location: dict[str, Any] | None = None
|
||||
homepage_url: str = Field(default="", max_length=2000)
|
||||
content_language: str = Field(default="", max_length=32)
|
||||
group_id: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class EarthNewsManualGroupPayload(BaseModel):
|
||||
name: str = Field(default="", max_length=120)
|
||||
|
||||
|
||||
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
|
||||
merged = DEFAULT_EARTH_BRAND.copy()
|
||||
if payload:
|
||||
@@ -375,6 +409,162 @@ async def test_earth_news_source(
|
||||
return await test_news_source_config(payload.source, db=db)
|
||||
|
||||
|
||||
@router.get("/news-groups")
|
||||
async def list_earth_news_groups_admin(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_news_groups(db)
|
||||
|
||||
|
||||
@router.post("/news-groups")
|
||||
async def create_earth_news_group_admin(
|
||||
payload: EarthNewsManualGroupPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
group = await create_manual_news_group(db, payload.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
return {"status": "ok", "group": group}
|
||||
|
||||
|
||||
@router.put("/news-groups/{group_id:path}")
|
||||
async def rename_earth_news_group_admin(
|
||||
group_id: str,
|
||||
payload: EarthNewsManualGroupPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
group = await rename_manual_news_group(db, group_id, payload.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", "group": group}
|
||||
|
||||
|
||||
@router.get("/news-items")
|
||||
async def list_earth_news_items_admin(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
source_type: str | None = Query(None),
|
||||
region: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
group_id: str | None = Query(None),
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_news_records(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
source_type=source_type,
|
||||
region=region,
|
||||
category=category,
|
||||
status_filter=status_filter,
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/news-items")
|
||||
async def create_earth_news_item_admin(
|
||||
payload: EarthNewsManualItemPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
result = await upsert_manual_news_item(db, payload.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
|
||||
|
||||
|
||||
@router.post("/news-items/import")
|
||||
async def import_earth_news_items_admin(
|
||||
file: UploadFile = File(...),
|
||||
group_id: str | None = Form(default=None),
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload = await parse_manual_news_import_upload(await file.read())
|
||||
result = await import_manual_news_items(db, payload, group_id=group_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.put("/news-items/{item_id:path}")
|
||||
async def update_earth_news_item_admin(
|
||||
item_id: str,
|
||||
payload: EarthNewsManualItemPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = await get_news_record_or_404(db, item_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="News item not found.")
|
||||
try:
|
||||
result = await upsert_manual_news_item(
|
||||
db,
|
||||
payload.model_dump(),
|
||||
item_id_override=item_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
|
||||
|
||||
|
||||
@router.delete("/news-items/{item_id:path}")
|
||||
async def delete_earth_news_item_admin(
|
||||
item_id: str,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
deleted = await delete_manual_news_item(db, item_id)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="News item not found.")
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "deleted", "id": item_id}
|
||||
|
||||
|
||||
@router.post("/news-items/{item_id:path}/reprocess")
|
||||
async def reprocess_earth_news_item_admin(
|
||||
item_id: str,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = await get_news_record_or_404(db, item_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="News item not found.")
|
||||
try:
|
||||
queued = await reprocess_manual_news_item(db, item_id)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await broadcast_manual_news_changed()
|
||||
return {"status": "queued" if queued else "not_queued", "queued": queued, "id": item_id}
|
||||
|
||||
|
||||
@router.get("/oobe-status")
|
||||
async def get_earth_oobe_status(
|
||||
current_user: User | None = Depends(_get_optional_current_user),
|
||||
|
||||
@@ -66,6 +66,7 @@ class NewsSourceType(StrEnum):
|
||||
ATOM = "atom"
|
||||
AGGREGATED = "aggregated"
|
||||
REFERENCE = "reference"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class NewsEnrichmentStatus(StrEnum):
|
||||
|
||||
@@ -2282,6 +2282,50 @@ def _filter_news_items_by_source_ids(
|
||||
]
|
||||
|
||||
|
||||
def _news_item_source_id(item: ParsedNewsItem) -> str:
|
||||
return item.id.split(":", 1)[0] if ":" in item.id else ""
|
||||
|
||||
|
||||
def _is_news_item_display_ready(item: ParsedNewsItem, *, locale: str) -> bool:
|
||||
return bool(
|
||||
_get_locale_text(item, "title", locale=locale)
|
||||
and _get_locale_text(item, "summary", locale=locale)
|
||||
)
|
||||
|
||||
|
||||
def _diversify_news_items_for_locale(
|
||||
items: list[ParsedNewsItem],
|
||||
*,
|
||||
active_region: str,
|
||||
limit: int,
|
||||
locale: str,
|
||||
) -> list[ParsedNewsItem]:
|
||||
ranked = sorted(
|
||||
_rank_and_trim_items(items, active_region=active_region, limit=max(len(items), limit)),
|
||||
key=lambda item: (not _is_news_item_display_ready(item, locale=locale),),
|
||||
)
|
||||
buckets: dict[str, list[ParsedNewsItem]] = {}
|
||||
order: list[str] = []
|
||||
for item in ranked:
|
||||
key = _news_item_source_id(item) or item.source or item.feed_name or item.id
|
||||
if key not in buckets:
|
||||
buckets[key] = []
|
||||
order.append(key)
|
||||
buckets[key].append(item)
|
||||
|
||||
diversified: list[ParsedNewsItem] = []
|
||||
while len(diversified) < limit and order:
|
||||
next_order: list[str] = []
|
||||
for source_id in order:
|
||||
bucket = buckets.get(source_id) or []
|
||||
if bucket and len(diversified) < limit:
|
||||
diversified.append(bucket.pop(0))
|
||||
if bucket:
|
||||
next_order.append(source_id)
|
||||
order = next_order
|
||||
return diversified
|
||||
|
||||
|
||||
async def _call_store_list_items(list_fn, db: AsyncSession, **kwargs):
|
||||
try:
|
||||
return await list_fn(db, **kwargs)
|
||||
@@ -2717,6 +2761,37 @@ async def get_earth_news_payload(
|
||||
categories=categories,
|
||||
source_ids=source_ids,
|
||||
)
|
||||
if not source_ids:
|
||||
ready_sources = {
|
||||
_news_item_source_id(item)
|
||||
for item in items
|
||||
if _is_news_item_display_ready(item, locale=locale)
|
||||
}
|
||||
missing_ready_sources = [
|
||||
source.id
|
||||
for source in sources
|
||||
if source.id and source.id not in ready_sources
|
||||
]
|
||||
if missing_ready_sources:
|
||||
extra_items: list[ParsedNewsItem] = []
|
||||
for missing_source_id in missing_ready_sources:
|
||||
extra_items.extend(
|
||||
await _call_store_list_items(
|
||||
list_earth_news_items,
|
||||
db,
|
||||
active_region=active_region,
|
||||
limit=3,
|
||||
categories=categories,
|
||||
source_ids={missing_source_id},
|
||||
)
|
||||
)
|
||||
if extra_items:
|
||||
items = _diversify_news_items_for_locale(
|
||||
[*items, *extra_items],
|
||||
active_region=active_region,
|
||||
limit=limit,
|
||||
locale=locale,
|
||||
)
|
||||
if hasattr(db, "execute"):
|
||||
cruise_items = await _call_store_list_items(
|
||||
list_earth_news_cruise_items,
|
||||
|
||||
693
backend/app/services/earth_news_manual.py
Normal file
693
backend/app/services/earth_news_manual.py
Normal file
@@ -0,0 +1,693 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.enums import NewsEnrichmentStatus, NewsSourceType, NewsTaggingSource
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.services.earth_news import (
|
||||
ALLOWED_NEWS_CATEGORY_KEYS,
|
||||
DEFAULT_NEWS_LOCALE,
|
||||
REGION_ANCHORS,
|
||||
NewsFeedEndpoint,
|
||||
NewsFeedSource,
|
||||
NewsTargetLocation,
|
||||
ParsedNewsItem,
|
||||
apply_news_classification,
|
||||
build_anchor_location_patch,
|
||||
build_target_location_job_payload,
|
||||
build_target_location_patch,
|
||||
_serialize_item,
|
||||
)
|
||||
from app.services.earth_news_queue import enqueue_target_location_job
|
||||
from app.services.earth_news_store import record_to_parsed_news_item
|
||||
|
||||
|
||||
MANUAL_NEWS_SOURCE_ID = "manual"
|
||||
MANUAL_NEWS_SOURCE_LABEL = "手动添加"
|
||||
MANUAL_NEWS_MAX_IMPORT_ITEMS = 500
|
||||
MANUAL_NEWS_MAX_TITLE_LENGTH = 500
|
||||
MANUAL_NEWS_MAX_SUMMARY_LENGTH = 1200
|
||||
MANUAL_NEWS_MAX_CONTENT_LENGTH = 12000
|
||||
EARTH_NEWS_MANUAL_GROUPS_CATEGORY = "earth_news_manual_groups"
|
||||
DEFAULT_MANUAL_NEWS_GROUP_ID = "manual-default"
|
||||
DEFAULT_MANUAL_NEWS_GROUP_NAME = "新建新闻组"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualNewsWriteResult:
|
||||
item: EarthNewsItem
|
||||
created: bool
|
||||
queued: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualNewsGroup:
|
||||
id: str
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
def _clean_text(value: object, *, max_length: int) -> str:
|
||||
raw = "" if value is None else str(value)
|
||||
text = BeautifulSoup(html.unescape(raw), "html.parser").get_text(" ", strip=True)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > max_length:
|
||||
return text[: max_length - 1].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime | None:
|
||||
if value is None or str(value).strip() == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("published_at 必须是 ISO8601 时间。") from exc
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def _detect_language(*parts: str) -> str:
|
||||
text = " ".join(part for part in parts if part)
|
||||
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
|
||||
latin_count = len(re.findall(r"[A-Za-z]", text))
|
||||
return "zh-CN" if cjk_count >= max(4, latin_count // 3) else "en-US"
|
||||
|
||||
|
||||
def _manual_item_id(*, title: str, published_at: datetime | None, url: str, source: str) -> str:
|
||||
published = published_at.isoformat() if published_at else ""
|
||||
basis = "\n".join([title.strip().lower(), published, url.strip().lower(), source.strip().lower()])
|
||||
return f"manual:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def _manual_group_id(name: str) -> str:
|
||||
basis = f"{name.strip().lower()}\n{datetime.now(UTC).isoformat()}"
|
||||
return f"manual-group:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:10]}"
|
||||
|
||||
|
||||
def _news_meta(record: EarthNewsItem) -> dict[str, Any]:
|
||||
location_meta = record.location_meta if isinstance(record.location_meta, dict) else {}
|
||||
news_meta = location_meta.get("news_meta")
|
||||
return dict(news_meta) if isinstance(news_meta, dict) else {}
|
||||
|
||||
|
||||
def _record_source_type(record: EarthNewsItem) -> str:
|
||||
return str(_news_meta(record).get("feed_type") or _news_meta(record).get("source_type") or "rss")
|
||||
|
||||
|
||||
def _record_manual_group_id(record: EarthNewsItem) -> str:
|
||||
return str(_news_meta(record).get("manual_group_id") or DEFAULT_MANUAL_NEWS_GROUP_ID)
|
||||
|
||||
|
||||
def _rss_group_id(record: EarthNewsItem) -> str:
|
||||
basis = "\n".join(
|
||||
[
|
||||
_record_source_type(record),
|
||||
str(record.feed_name or ""),
|
||||
str(record.source or ""),
|
||||
]
|
||||
)
|
||||
return f"rss:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:12]}"
|
||||
|
||||
|
||||
def _default_manual_group() -> dict[str, Any]:
|
||||
return {
|
||||
"id": DEFAULT_MANUAL_NEWS_GROUP_ID,
|
||||
"name": DEFAULT_MANUAL_NEWS_GROUP_NAME,
|
||||
"sort_order": 0,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_manual_groups_payload(payload: Any) -> list[dict[str, Any]]:
|
||||
raw_groups = payload.get("groups") if isinstance(payload, dict) else None
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for index, item in enumerate(raw_groups if isinstance(raw_groups, list) else []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
group_id = str(item.get("id") or "").strip()
|
||||
name = _clean_text(item.get("name"), max_length=120)
|
||||
if not group_id or not name or group_id in seen:
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": group_id,
|
||||
"name": name,
|
||||
"sort_order": int(item.get("sort_order") or index),
|
||||
}
|
||||
)
|
||||
seen.add(group_id)
|
||||
if DEFAULT_MANUAL_NEWS_GROUP_ID not in seen:
|
||||
normalized.insert(0, _default_manual_group())
|
||||
return sorted(normalized, key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or "")))
|
||||
|
||||
|
||||
async def _get_manual_groups_record(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_MANUAL_GROUPS_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_manual_news_groups(db: AsyncSession) -> list[dict[str, Any]]:
|
||||
record = await _get_manual_groups_record(db)
|
||||
return _normalize_manual_groups_payload(record.payload if record else None)
|
||||
|
||||
|
||||
async def _save_manual_news_groups(db: AsyncSession, groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
normalized = _normalize_manual_groups_payload({"groups": groups})
|
||||
record = await _get_manual_groups_record(db)
|
||||
payload = {"groups": normalized}
|
||||
if record is None:
|
||||
db.add(SystemSetting(category=EARTH_NEWS_MANUAL_GROUPS_CATEGORY, payload=payload))
|
||||
else:
|
||||
record.payload = payload
|
||||
await db.flush()
|
||||
return normalized
|
||||
|
||||
|
||||
async def resolve_manual_news_group(db: AsyncSession, group_id: str | None) -> ManualNewsGroup:
|
||||
normalized_id = str(group_id or DEFAULT_MANUAL_NEWS_GROUP_ID).strip() or DEFAULT_MANUAL_NEWS_GROUP_ID
|
||||
groups = await get_manual_news_groups(db)
|
||||
match = next((item for item in groups if item.get("id") == normalized_id), None)
|
||||
if match is None and normalized_id != DEFAULT_MANUAL_NEWS_GROUP_ID:
|
||||
raise ValueError(f"手动新闻组不存在:{normalized_id}")
|
||||
match = match or _default_manual_group()
|
||||
return ManualNewsGroup(
|
||||
id=str(match["id"]),
|
||||
name=str(match["name"]),
|
||||
sort_order=int(match.get("sort_order") or 0),
|
||||
)
|
||||
|
||||
|
||||
async def create_manual_news_group(db: AsyncSession, name: str) -> dict[str, Any]:
|
||||
group_name = _clean_text(name, max_length=120)
|
||||
if not group_name:
|
||||
raise ValueError("新闻组名称不能为空。")
|
||||
groups = await get_manual_news_groups(db)
|
||||
group = {"id": _manual_group_id(group_name), "name": group_name, "sort_order": len(groups)}
|
||||
groups.append(group)
|
||||
await _save_manual_news_groups(db, groups)
|
||||
return group
|
||||
|
||||
|
||||
async def rename_manual_news_group(db: AsyncSession, group_id: str, name: str) -> dict[str, Any]:
|
||||
group_name = _clean_text(name, max_length=120)
|
||||
if not group_name:
|
||||
raise ValueError("新闻组名称不能为空。")
|
||||
groups = await get_manual_news_groups(db)
|
||||
match = next((item for item in groups if item.get("id") == group_id), None)
|
||||
if match is None:
|
||||
raise ValueError(f"手动新闻组不存在:{group_id}")
|
||||
match["name"] = group_name
|
||||
await _save_manual_news_groups(db, groups)
|
||||
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem).where(
|
||||
EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == NewsSourceType.MANUAL.value
|
||||
)
|
||||
)
|
||||
for record in result.scalars().all():
|
||||
if _record_manual_group_id(record) != group_id:
|
||||
continue
|
||||
location_meta = dict(record.location_meta or {})
|
||||
news_meta = dict(location_meta.get("news_meta") or {})
|
||||
news_meta["manual_group_name"] = group_name
|
||||
location_meta["news_meta"] = news_meta
|
||||
record.location_meta = location_meta
|
||||
await db.flush()
|
||||
return match
|
||||
|
||||
|
||||
def _normalize_region(value: object) -> str:
|
||||
region = str(value or "global").strip().lower() or "global"
|
||||
if region not in REGION_ANCHORS:
|
||||
raise ValueError(f"region 不支持:{region}")
|
||||
return region
|
||||
|
||||
|
||||
def _normalize_tags(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = re.split(r"[,,\n]", value)
|
||||
elif isinstance(value, list):
|
||||
parts = [str(item) for item in value]
|
||||
else:
|
||||
raise ValueError("tags 必须是字符串数组或逗号分隔字符串。")
|
||||
return [item.strip() for item in parts if item.strip()][:20]
|
||||
|
||||
|
||||
def _normalize_location(value: object) -> NewsTargetLocation | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("location 必须是对象。")
|
||||
lat = value.get("latitude")
|
||||
lon = value.get("longitude")
|
||||
if lat in (None, "") and lon in (None, ""):
|
||||
return None
|
||||
try:
|
||||
latitude = float(lat)
|
||||
longitude = float(lon)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("location.latitude / longitude 必须是数字。") from exc
|
||||
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
|
||||
raise ValueError("location 经纬度超出范围。")
|
||||
label = _clean_text(value.get("label"), max_length=255)
|
||||
if not label:
|
||||
label = f"{latitude:.4f}, {longitude:.4f}"
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=label,
|
||||
source="manual_location",
|
||||
confidence=1.0,
|
||||
country=_clean_text(value.get("country"), max_length=100) or None,
|
||||
city=_clean_text(value.get("city"), max_length=100) or None,
|
||||
)
|
||||
|
||||
|
||||
def _manual_source(source_name: str, *, region: str) -> NewsFeedSource:
|
||||
return NewsFeedSource(
|
||||
id=MANUAL_NEWS_SOURCE_ID,
|
||||
name=source_name or MANUAL_NEWS_SOURCE_LABEL,
|
||||
region=region,
|
||||
feed_url="",
|
||||
homepage_url="",
|
||||
source_type=NewsSourceType.MANUAL.value,
|
||||
default_category="other",
|
||||
source_tags=("manual",),
|
||||
)
|
||||
|
||||
|
||||
def _manual_feed(category: str) -> NewsFeedEndpoint:
|
||||
return NewsFeedEndpoint(
|
||||
id=MANUAL_NEWS_SOURCE_ID,
|
||||
name=MANUAL_NEWS_SOURCE_LABEL,
|
||||
url="",
|
||||
type=NewsSourceType.MANUAL.value,
|
||||
default_category=category or "other",
|
||||
tags=("manual",),
|
||||
priority=1,
|
||||
)
|
||||
|
||||
|
||||
def parsed_manual_news_item(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
item_id_override: str | None = None,
|
||||
) -> tuple[ParsedNewsItem, NewsTargetLocation | None, str]:
|
||||
title = _clean_text(payload.get("title"), max_length=MANUAL_NEWS_MAX_TITLE_LENGTH)
|
||||
if not title:
|
||||
raise ValueError("title 不能为空。")
|
||||
content = _clean_text(payload.get("content"), max_length=MANUAL_NEWS_MAX_CONTENT_LENGTH)
|
||||
summary = _clean_text(payload.get("summary"), max_length=MANUAL_NEWS_MAX_SUMMARY_LENGTH)
|
||||
if not summary:
|
||||
summary = _clean_text(content, max_length=240) if content else title
|
||||
source = _clean_text(payload.get("source"), max_length=255) or MANUAL_NEWS_SOURCE_LABEL
|
||||
region = _normalize_region(payload.get("region"))
|
||||
published_at = _parse_datetime(payload.get("published_at")) or datetime.now(UTC)
|
||||
url = str(payload.get("url") or "").strip()
|
||||
category = str(payload.get("category") or "other").strip().lower() or "other"
|
||||
if category not in ALLOWED_NEWS_CATEGORY_KEYS:
|
||||
raise ValueError(f"category 不支持:{category}")
|
||||
tags = _normalize_tags(payload.get("tags"))
|
||||
target = _normalize_location(payload.get("location"))
|
||||
language = str(payload.get("content_language") or "").strip() or _detect_language(title, summary, content)
|
||||
localizations = {
|
||||
language: {
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
}
|
||||
}
|
||||
item = ParsedNewsItem(
|
||||
id=item_id_override
|
||||
or _manual_item_id(title=title, published_at=published_at, url=url, source=source),
|
||||
title=title,
|
||||
summary=summary,
|
||||
url=url,
|
||||
source=source,
|
||||
feed_name=MANUAL_NEWS_SOURCE_LABEL,
|
||||
feed_region=region,
|
||||
homepage_url=str(payload.get("homepage_url") or ""),
|
||||
published_at=published_at,
|
||||
content_language=language,
|
||||
localizations=localizations,
|
||||
enrichment_status=NewsEnrichmentStatus.PENDING.value,
|
||||
source_tags=["manual"],
|
||||
feed_id=MANUAL_NEWS_SOURCE_ID,
|
||||
feed_type=NewsSourceType.MANUAL.value,
|
||||
feed_default_category=category,
|
||||
category=category,
|
||||
item_tags=tags,
|
||||
tagging_source=NewsTaggingSource.MANUAL.value if payload.get("category") else NewsTaggingSource.RULES.value,
|
||||
tagging_confidence=0.9 if payload.get("category") else 0.0,
|
||||
)
|
||||
source_config = _manual_source(source, region=region)
|
||||
feed = _manual_feed(category)
|
||||
apply_news_classification(item, source_config, feed=feed)
|
||||
if payload.get("category"):
|
||||
item.category = category
|
||||
item.tagging_source = NewsTaggingSource.MANUAL.value
|
||||
item.tagging_confidence = 0.9
|
||||
if tags:
|
||||
item.item_tags = sorted(set([*item.item_tags, *tags]))
|
||||
return item, target, content
|
||||
|
||||
|
||||
def _manual_editable(record: EarthNewsItem) -> bool:
|
||||
if record.id.startswith("manual:"):
|
||||
return True
|
||||
news_meta = (record.location_meta or {}).get("news_meta") if isinstance(record.location_meta, dict) else None
|
||||
return isinstance(news_meta, dict) and news_meta.get("feed_type") == NewsSourceType.MANUAL.value
|
||||
|
||||
|
||||
async def _broadcast_news_reload() -> None:
|
||||
await broadcaster.broadcast_earth_update(
|
||||
{
|
||||
"action": "database_changed",
|
||||
"source": "earth_news_items",
|
||||
"layers": ["news"],
|
||||
"refresh_strategy": "reload",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def upsert_manual_news_item(
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
item_id_override: str | None = None,
|
||||
group_id: str | None = None,
|
||||
) -> ManualNewsWriteResult:
|
||||
item, target, content = parsed_manual_news_item(payload, item_id_override=item_id_override)
|
||||
group = await resolve_manual_news_group(db, group_id or payload.get("group_id"))
|
||||
existing = await db.get(EarthNewsItem, item.id)
|
||||
created = existing is None
|
||||
patch = build_target_location_patch(item, target) if target else build_anchor_location_patch(item)
|
||||
patch_meta = dict(patch.get("location_meta") or {})
|
||||
patch_news_meta = dict(patch_meta.get("news_meta") or {})
|
||||
patch_news_meta["feed_type"] = NewsSourceType.MANUAL.value
|
||||
patch_news_meta["source_type"] = NewsSourceType.MANUAL.value
|
||||
patch_news_meta["manual_group_id"] = group.id
|
||||
patch_news_meta["manual_group_name"] = group.name
|
||||
patch_meta["news_meta"] = patch_news_meta
|
||||
patch["location_meta"] = patch_meta
|
||||
now = datetime.now(UTC)
|
||||
record = existing or EarthNewsItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
summary=item.summary,
|
||||
content_language=item.content_language,
|
||||
localizations=dict(item.localizations or {}),
|
||||
url=item.url,
|
||||
source=item.source,
|
||||
feed_name=item.feed_name,
|
||||
region=item.feed_region,
|
||||
homepage_url=item.homepage_url,
|
||||
published_at=item.published_at,
|
||||
latitude=patch["latitude"],
|
||||
longitude=patch["longitude"],
|
||||
location_label=patch["location_label"],
|
||||
location_source=patch["location_source"],
|
||||
verified=patch["verified"],
|
||||
location_meta=patch["location_meta"],
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
resolved_at=now if patch["verified"] else None,
|
||||
enrichment_status=item.enrichment_status,
|
||||
)
|
||||
if existing is None:
|
||||
db.add(record)
|
||||
else:
|
||||
if not _manual_editable(record):
|
||||
raise PermissionError("RSS 新闻不允许通过手动新闻接口编辑。")
|
||||
record.title = item.title
|
||||
record.summary = item.summary
|
||||
record.content_language = item.content_language
|
||||
record.localizations = dict(item.localizations or {})
|
||||
record.url = item.url
|
||||
record.source = item.source
|
||||
record.feed_name = item.feed_name
|
||||
record.region = item.feed_region
|
||||
record.homepage_url = item.homepage_url
|
||||
record.published_at = item.published_at
|
||||
record.last_seen_at = now
|
||||
if target is None and record.location_source == "manual_location":
|
||||
merged_meta = dict(record.location_meta or {})
|
||||
patch_meta = patch.get("location_meta") if isinstance(patch, dict) else None
|
||||
patch_news_meta = patch_meta.get("news_meta") if isinstance(patch_meta, dict) else None
|
||||
if isinstance(patch_news_meta, dict):
|
||||
merged_meta["news_meta"] = patch_news_meta
|
||||
record.location_meta = merged_meta
|
||||
else:
|
||||
record.location_meta = patch["location_meta"]
|
||||
if target:
|
||||
record.latitude = patch["latitude"]
|
||||
record.longitude = patch["longitude"]
|
||||
record.location_label = patch["location_label"]
|
||||
record.location_source = patch["location_source"]
|
||||
record.verified = patch["verified"]
|
||||
record.resolved_at = now
|
||||
elif record.location_source != "manual_location":
|
||||
record.latitude = patch["latitude"]
|
||||
record.longitude = patch["longitude"]
|
||||
record.location_label = patch["location_label"]
|
||||
record.location_source = patch["location_source"]
|
||||
record.verified = patch["verified"]
|
||||
record.resolved_at = None
|
||||
record.enrichment_status = NewsEnrichmentStatus.PENDING.value
|
||||
record.enrichment_error = None
|
||||
record.enriched_at = None
|
||||
if content:
|
||||
meta = dict(record.location_meta or {})
|
||||
meta["manual_content"] = content
|
||||
record.location_meta = meta
|
||||
await db.flush()
|
||||
|
||||
queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True)
|
||||
if queued:
|
||||
record.enrichment_status = NewsEnrichmentStatus.QUEUED.value
|
||||
await db.flush()
|
||||
return ManualNewsWriteResult(item=record, created=created, queued=queued)
|
||||
|
||||
|
||||
async def import_manual_news_items(
|
||||
db: AsyncSession,
|
||||
payload: list[Any],
|
||||
*,
|
||||
group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if len(payload) > MANUAL_NEWS_MAX_IMPORT_ITEMS:
|
||||
raise ValueError(f"单次最多导入 {MANUAL_NEWS_MAX_IMPORT_ITEMS} 条。")
|
||||
created = 0
|
||||
updated = 0
|
||||
queued = 0
|
||||
errors: list[dict[str, Any]] = []
|
||||
for index, raw_item in enumerate(payload):
|
||||
if not isinstance(raw_item, dict):
|
||||
errors.append({"index": index, "error": "条目必须是 JSON 对象。"})
|
||||
continue
|
||||
try:
|
||||
result = await upsert_manual_news_item(db, raw_item, group_id=group_id)
|
||||
created += 1 if result.created else 0
|
||||
updated += 0 if result.created else 1
|
||||
queued += 1 if result.queued else 0
|
||||
except Exception as exc:
|
||||
errors.append({"index": index, "error": str(exc)})
|
||||
if errors and created == 0 and updated == 0:
|
||||
raise ValueError("导入失败,未写入任何新闻。")
|
||||
return {"created": created, "updated": updated, "queued": queued, "failed": len(errors), "errors": errors}
|
||||
|
||||
|
||||
async def parse_manual_news_import_upload(raw_bytes: bytes) -> list[Any]:
|
||||
try:
|
||||
payload = json.loads(raw_bytes.decode("utf-8-sig"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("JSON 文件必须使用 UTF-8 编码。") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"JSON 解析失败:第 {exc.lineno} 行第 {exc.colno} 列。") from exc
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("JSON 顶层必须是数组。")
|
||||
return payload
|
||||
|
||||
|
||||
def serialize_news_record(record: EarthNewsItem, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]:
|
||||
item = record_to_parsed_news_item(record)
|
||||
payload = _serialize_item(item, active_region=item.feed_region, locale=locale)
|
||||
news_meta = _news_meta(record)
|
||||
payload["editable"] = _manual_editable(record)
|
||||
payload["source_type"] = payload.get("feed_type")
|
||||
payload["status"] = record.enrichment_status
|
||||
payload["translated"] = bool((record.localizations or {}).get("zh-CN") and (record.localizations or {}).get("en-US"))
|
||||
payload["manual_content"] = (record.location_meta or {}).get("manual_content") if isinstance(record.location_meta, dict) else None
|
||||
payload["manual_group_id"] = news_meta.get("manual_group_id")
|
||||
payload["manual_group_name"] = news_meta.get("manual_group_name")
|
||||
return payload
|
||||
|
||||
|
||||
def _record_matches_group(record: EarthNewsItem, group_id: str) -> bool:
|
||||
source_type = _record_source_type(record)
|
||||
if source_type == NewsSourceType.MANUAL.value:
|
||||
return _record_manual_group_id(record) == group_id
|
||||
return _rss_group_id(record) == group_id
|
||||
|
||||
|
||||
async def list_news_records(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
source_type: str | None = None,
|
||||
region: str | None = None,
|
||||
category: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
page = max(page, 1)
|
||||
page_size = min(max(page_size, 1), 100)
|
||||
query = select(EarthNewsItem)
|
||||
count_query = select(func.count(EarthNewsItem.id))
|
||||
filters = []
|
||||
if region and region != "all":
|
||||
filters.append(EarthNewsItem.region == region)
|
||||
if status_filter and status_filter != "all":
|
||||
filters.append(EarthNewsItem.enrichment_status == status_filter)
|
||||
if source_type and source_type != "all":
|
||||
filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == source_type)
|
||||
if category and category != "all":
|
||||
filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("category") == category)
|
||||
for clause in filters:
|
||||
query = query.where(clause)
|
||||
count_query = count_query.where(clause)
|
||||
ordered_query = query.order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc())
|
||||
if group_id:
|
||||
result = await db.execute(ordered_query)
|
||||
all_records = [record for record in result.scalars().all() if _record_matches_group(record, group_id)]
|
||||
total = len(all_records)
|
||||
records = all_records[(page - 1) * page_size : page * page_size]
|
||||
else:
|
||||
total_result = await db.execute(count_query)
|
||||
result = await db.execute(
|
||||
ordered_query.offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
total = int(total_result.scalar() or 0)
|
||||
return {
|
||||
"items": [serialize_news_record(record) for record in records],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
async def list_news_groups(db: AsyncSession, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]:
|
||||
manual_groups = await get_manual_news_groups(db)
|
||||
manual_by_id: dict[str, dict[str, Any]] = {
|
||||
str(group["id"]): {
|
||||
"id": str(group["id"]),
|
||||
"name": str(group["name"]),
|
||||
"group_type": "manual",
|
||||
"source_type": NewsSourceType.MANUAL.value,
|
||||
"editable": True,
|
||||
"sort_order": int(group.get("sort_order") or 0),
|
||||
"count": 0,
|
||||
"items": [],
|
||||
}
|
||||
for group in manual_groups
|
||||
}
|
||||
rss_by_id: dict[str, dict[str, Any]] = {}
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem).order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc())
|
||||
)
|
||||
for record in result.scalars().all():
|
||||
serialized = serialize_news_record(record, locale=locale)
|
||||
source_type = _record_source_type(record)
|
||||
if source_type == NewsSourceType.MANUAL.value:
|
||||
group_id = _record_manual_group_id(record)
|
||||
group = manual_by_id.setdefault(
|
||||
group_id,
|
||||
{
|
||||
"id": group_id,
|
||||
"name": str(_news_meta(record).get("manual_group_name") or DEFAULT_MANUAL_NEWS_GROUP_NAME),
|
||||
"group_type": "manual",
|
||||
"source_type": NewsSourceType.MANUAL.value,
|
||||
"editable": True,
|
||||
"sort_order": len(manual_by_id),
|
||||
"count": 0,
|
||||
"items": [],
|
||||
},
|
||||
)
|
||||
else:
|
||||
group_id = _rss_group_id(record)
|
||||
group = rss_by_id.setdefault(
|
||||
group_id,
|
||||
{
|
||||
"id": group_id,
|
||||
"name": record.feed_name or record.source or "RSS 新闻",
|
||||
"group_type": "rss",
|
||||
"source_type": source_type,
|
||||
"editable": False,
|
||||
"region": record.region,
|
||||
"source": record.source,
|
||||
"feed_name": record.feed_name,
|
||||
"count": 0,
|
||||
"items": [],
|
||||
},
|
||||
)
|
||||
group["count"] = int(group.get("count") or 0) + 1
|
||||
group.setdefault("items", []).append(serialized)
|
||||
manual_items = sorted(manual_by_id.values(), key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or "")))
|
||||
rss_items = sorted(rss_by_id.values(), key=lambda item: str(item.get("name") or ""))
|
||||
return {"groups": [*manual_items, *rss_items], "manual_groups": manual_items, "rss_groups": rss_items}
|
||||
|
||||
|
||||
async def get_news_record_or_404(db: AsyncSession, item_id: str) -> EarthNewsItem | None:
|
||||
return await db.get(EarthNewsItem, item_id)
|
||||
|
||||
|
||||
async def delete_manual_news_item(db: AsyncSession, item_id: str) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if not _manual_editable(record):
|
||||
raise PermissionError("RSS 新闻不允许通过手动新闻接口删除。")
|
||||
await db.execute(delete(EarthNewsItem).where(EarthNewsItem.id == item_id))
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def reprocess_manual_news_item(db: AsyncSession, item_id: str) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if not _manual_editable(record):
|
||||
raise PermissionError("RSS 新闻不允许通过手动新闻接口重新处理。")
|
||||
item = record_to_parsed_news_item(record)
|
||||
queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True)
|
||||
if queued:
|
||||
record.enrichment_status = NewsEnrichmentStatus.QUEUED.value
|
||||
record.enrichment_error = None
|
||||
await db.flush()
|
||||
return queued
|
||||
|
||||
|
||||
async def broadcast_manual_news_changed() -> None:
|
||||
await _broadcast_news_reload()
|
||||
@@ -133,42 +133,6 @@ def _source_filter_clause(source_ids: set[str] | None):
|
||||
return EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("source_id").in_(sorted(source_ids))
|
||||
|
||||
|
||||
def _record_source_id(record: EarthNewsItem) -> str:
|
||||
location_meta = dict(record.location_meta or {})
|
||||
news_meta = location_meta.get("news_meta") if isinstance(location_meta.get("news_meta"), dict) else {}
|
||||
source_id = str(news_meta.get("source_id") or "").strip()
|
||||
if source_id:
|
||||
return source_id
|
||||
if isinstance(record.id, str) and ":" in record.id:
|
||||
return record.id.split(":", 1)[0]
|
||||
return record.feed_name or record.source or record.id
|
||||
|
||||
|
||||
def _diversify_records_by_source(records: list[EarthNewsItem], *, limit: int) -> list[EarthNewsItem]:
|
||||
if limit <= 0 or len(records) <= limit:
|
||||
return records[:limit]
|
||||
buckets: dict[str, list[EarthNewsItem]] = {}
|
||||
order: list[str] = []
|
||||
for record in records:
|
||||
source_id = _record_source_id(record)
|
||||
if source_id not in buckets:
|
||||
buckets[source_id] = []
|
||||
order.append(source_id)
|
||||
buckets[source_id].append(record)
|
||||
|
||||
diversified: list[EarthNewsItem] = []
|
||||
while len(diversified) < limit and order:
|
||||
next_order: list[str] = []
|
||||
for source_id in order:
|
||||
bucket = buckets.get(source_id) or []
|
||||
if bucket and len(diversified) < limit:
|
||||
diversified.append(bucket.pop(0))
|
||||
if bucket:
|
||||
next_order.append(source_id)
|
||||
order = next_order
|
||||
return diversified
|
||||
|
||||
|
||||
async def list_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -177,7 +141,7 @@ async def list_earth_news_items(
|
||||
categories: set[str] | None = None,
|
||||
source_ids: set[str] | None = None,
|
||||
) -> list[ParsedNewsItem]:
|
||||
query_limit = limit if source_ids else min(max(limit * 8, limit), 200)
|
||||
query_limit = limit if source_ids else min(max(limit * 20, limit), 500)
|
||||
query = (
|
||||
select(EarthNewsItem)
|
||||
.order_by(*_query_sort_key(active_region))
|
||||
@@ -382,13 +346,28 @@ async def update_earth_news_item_enrichment(
|
||||
if record is None:
|
||||
return False
|
||||
if "latitude" in patch:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
patch_meta = dict(patch.get("location_meta") or {})
|
||||
if record.location_source == "manual_location":
|
||||
current_meta = dict(record.location_meta or {})
|
||||
patch_news_meta = patch_meta.get("news_meta")
|
||||
if isinstance(patch_news_meta, dict):
|
||||
current_meta["news_meta"] = patch_news_meta
|
||||
current_meta["manual_enrichment"] = {
|
||||
"resolution_stage": patch_meta.get("resolution_stage"),
|
||||
"ai_attempted": patch_meta.get("ai_attempted"),
|
||||
"ai_status": patch_meta.get("ai_status"),
|
||||
"ai_error": patch_meta.get("ai_error"),
|
||||
"debug_note": patch_meta.get("debug_note"),
|
||||
}
|
||||
record.location_meta = current_meta
|
||||
else:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = patch_meta
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
if "content_language" in patch:
|
||||
record.content_language = str(patch.get("content_language") or "en")
|
||||
if "localizations" in patch:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[pytest]
|
||||
pythonpath = ..
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.services.earth_news import (
|
||||
default_earth_news_sources_payload,
|
||||
normalize_earth_news_sources_payload,
|
||||
_fetch_source,
|
||||
_diversify_news_items_for_locale,
|
||||
_enrich_items_with_target_locations,
|
||||
_extract_target_location_from_text,
|
||||
_parse_feed_entries,
|
||||
@@ -130,6 +131,47 @@ def test_rank_and_trim_items_prioritizes_active_breaking():
|
||||
assert [item.id for item in ranked] == ["global:critical", "europe:expired", "europe:regular"]
|
||||
|
||||
|
||||
def test_diversify_news_items_prefers_display_ready_content_across_sources():
|
||||
published_at = datetime(2026, 6, 11, 3, 0, tzinfo=UTC)
|
||||
|
||||
def make_item(source_id: str, suffix: str, *, zh_ready: bool) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
id=f"{source_id}:{suffix}",
|
||||
title=f"{source_id} title {suffix}",
|
||||
summary=f"{source_id} summary {suffix}",
|
||||
url=f"https://example.com/{source_id}/{suffix}",
|
||||
source=source_id,
|
||||
feed_name=source_id,
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=published_at,
|
||||
content_language="en",
|
||||
localizations={
|
||||
"zh-CN": {
|
||||
"title": f"{source_id} 中文标题 {suffix}",
|
||||
"summary": f"{source_id} 中文摘要 {suffix}",
|
||||
}
|
||||
} if zh_ready else {},
|
||||
)
|
||||
|
||||
items = [
|
||||
make_item("source-a", "1", zh_ready=False),
|
||||
make_item("source-a", "2", zh_ready=False),
|
||||
make_item("source-a", "3", zh_ready=False),
|
||||
make_item("source-b", "1", zh_ready=True),
|
||||
make_item("source-c", "1", zh_ready=True),
|
||||
]
|
||||
|
||||
result = _diversify_news_items_for_locale(
|
||||
items,
|
||||
active_region="global",
|
||||
limit=3,
|
||||
locale="zh-CN",
|
||||
)
|
||||
|
||||
assert [item.id.split(":", 1)[0] for item in result] == ["source-b", "source-c", "source-a"]
|
||||
|
||||
|
||||
def test_serialize_item_falls_back_to_global_anchor():
|
||||
item = ParsedNewsItem(
|
||||
id="custom:test",
|
||||
@@ -961,9 +1003,11 @@ async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypa
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 12, datetime.now(UTC)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None):
|
||||
assert limit == 12
|
||||
return [item]
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None):
|
||||
if source_ids is None:
|
||||
assert limit == 12
|
||||
return [item]
|
||||
return []
|
||||
|
||||
async def fail_fetch(_sources):
|
||||
raise AssertionError("fresh database items should not fetch RSS")
|
||||
@@ -1062,8 +1106,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None):
|
||||
captured["items_region"] = active_region
|
||||
captured["items_categories"] = categories
|
||||
captured["items_source_ids"] = source_ids
|
||||
return [item]
|
||||
captured.setdefault("items_source_ids", []).append(source_ids)
|
||||
return [item] if source_ids is None else []
|
||||
|
||||
async def fake_list_earth_news_cruise_items(_db, *, limit, categories=None, source_ids=None):
|
||||
captured["cruise_categories"] = categories
|
||||
@@ -1090,7 +1134,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo
|
||||
assert captured["freshness_region"] == "europe"
|
||||
assert captured["items_region"] == "europe"
|
||||
assert captured["items_categories"] == {"business", "ecommerce"}
|
||||
assert captured["items_source_ids"] is None
|
||||
assert captured["items_source_ids"][0] is None
|
||||
assert any(source_ids for source_ids in captured["items_source_ids"][1:])
|
||||
assert captured["cruise_categories"] == {"business", "ecommerce"}
|
||||
assert captured["cruise_source_ids"] is None
|
||||
assert payload["filters"] == {
|
||||
|
||||
252
backend/tests/test_earth_news_manual.py
Normal file
252
backend/tests/test_earth_news_manual.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.enums import NewsSourceType
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.services.earth_news import REGION_ANCHORS
|
||||
from app.services.earth_news_manual import (
|
||||
DEFAULT_MANUAL_NEWS_GROUP_ID,
|
||||
create_manual_news_group,
|
||||
import_manual_news_items,
|
||||
list_news_groups,
|
||||
list_news_records,
|
||||
parse_manual_news_import_upload,
|
||||
rename_manual_news_group,
|
||||
upsert_manual_news_item,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows=None, scalar=None):
|
||||
self.rows = rows or []
|
||||
self._scalar = scalar
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._scalar
|
||||
|
||||
def scalar(self):
|
||||
return self._scalar
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _FakeNewsSession:
|
||||
def __init__(self, records=None, setting=None):
|
||||
self.records = dict(records or {})
|
||||
self.setting = setting
|
||||
|
||||
async def get(self, _model, item_id):
|
||||
return self.records.get(item_id)
|
||||
|
||||
def add(self, item):
|
||||
if isinstance(item, SystemSetting):
|
||||
self.setting = item
|
||||
else:
|
||||
self.records[item.id] = item
|
||||
|
||||
async def execute(self, stmt):
|
||||
statement = str(stmt)
|
||||
if "system_settings" in statement:
|
||||
return _FakeResult(scalar=self.setting)
|
||||
if "count" in statement.lower():
|
||||
return _FakeResult(scalar=len(self.records))
|
||||
return _FakeResult(rows=list(self.records.values()))
|
||||
|
||||
async def flush(self):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_news_queue(monkeypatch):
|
||||
queued = []
|
||||
|
||||
async def _enqueue(payload, force=False):
|
||||
queued.append({"payload": payload, "force": force})
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_manual.enqueue_target_location_job", _enqueue)
|
||||
return queued
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_upsert_uses_region_anchor_and_manual_metadata(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
|
||||
result = await upsert_manual_news_item(
|
||||
db,
|
||||
{
|
||||
"title": "手动添加的新闻",
|
||||
"summary": "一条用于测试的手动新闻。",
|
||||
"source": "人工录入",
|
||||
"region": "europe",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"tags": ["manual", "test"],
|
||||
},
|
||||
)
|
||||
|
||||
anchor = REGION_ANCHORS["europe"]
|
||||
assert result.created is True
|
||||
assert result.queued is True
|
||||
assert result.item.id.startswith("manual:")
|
||||
assert result.item.feed_name == "手动添加"
|
||||
assert result.item.source == "人工录入"
|
||||
assert result.item.latitude == anchor.latitude
|
||||
assert result.item.longitude == anchor.longitude
|
||||
assert result.item.location_source == "region_anchor"
|
||||
assert result.item.verified is False
|
||||
assert result.item.location_meta["news_meta"]["feed_type"] == NewsSourceType.MANUAL.value
|
||||
assert result.item.location_meta["news_meta"]["source_type"] == NewsSourceType.MANUAL.value
|
||||
assert result.item.location_meta["news_meta"]["manual_group_id"] == DEFAULT_MANUAL_NEWS_GROUP_ID
|
||||
assert len(fake_news_queue) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_duplicate_import_upserts_without_duplicate_rows(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
payload = {
|
||||
"title": "Same manual story",
|
||||
"source": "Manual Desk",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"region": "global",
|
||||
}
|
||||
|
||||
first = await upsert_manual_news_item(db, payload)
|
||||
second = await upsert_manual_news_item(db, {**payload, "summary": "Updated summary"})
|
||||
|
||||
assert first.created is True
|
||||
assert second.created is False
|
||||
assert len(db.records) == 1
|
||||
assert db.records[first.item.id].summary == "Updated summary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_edit_without_location_preserves_manual_coordinates(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
created = await upsert_manual_news_item(
|
||||
db,
|
||||
{
|
||||
"title": "Taipei-1 data center update",
|
||||
"summary": "Initial summary.",
|
||||
"region": "asia-pacific",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"location": {"label": "Kaohsiung, Taiwan", "latitude": 22.6273, "longitude": 120.3014},
|
||||
},
|
||||
)
|
||||
|
||||
updated = await upsert_manual_news_item(
|
||||
db,
|
||||
{
|
||||
"title": "Taipei-1 data center update",
|
||||
"summary": "Edited summary only.",
|
||||
"region": "asia-pacific",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
},
|
||||
item_id_override=created.item.id,
|
||||
)
|
||||
|
||||
assert updated.created is False
|
||||
assert updated.item.latitude == pytest.approx(22.6273)
|
||||
assert updated.item.longitude == pytest.approx(120.3014)
|
||||
assert updated.item.location_source == "manual_location"
|
||||
assert updated.item.verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_api_service_rejects_rss_records(fake_news_queue):
|
||||
rss_record = EarthNewsItem(
|
||||
id="bbc-world:example",
|
||||
title="RSS story",
|
||||
summary="RSS summary",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
region="global",
|
||||
latitude=20,
|
||||
longitude=0,
|
||||
location_label="全球",
|
||||
location_source="region_anchor",
|
||||
verified=False,
|
||||
location_meta={"news_meta": {"feed_type": "rss"}},
|
||||
first_seen_at=datetime.now(UTC),
|
||||
last_seen_at=datetime.now(UTC),
|
||||
)
|
||||
db = _FakeNewsSession({rss_record.id: rss_record})
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await upsert_manual_news_item(
|
||||
db,
|
||||
{"title": "Edited title", "region": "global"},
|
||||
item_id_override=rss_record.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_import_reports_per_item_errors(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
|
||||
result = await import_manual_news_items(
|
||||
db,
|
||||
[
|
||||
{"title": "Valid manual news", "region": "global"},
|
||||
{"summary": "missing title"},
|
||||
],
|
||||
)
|
||||
|
||||
assert result["created"] == 1
|
||||
assert result["failed"] == 1
|
||||
assert result["errors"][0]["index"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_groups_default_create_and_rename(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
|
||||
initial = await list_news_groups(db)
|
||||
assert initial["manual_groups"][0]["id"] == DEFAULT_MANUAL_NEWS_GROUP_ID
|
||||
assert initial["manual_groups"][0]["name"] == "新建新闻组"
|
||||
|
||||
group = await create_manual_news_group(db, "专题组")
|
||||
assert group["name"] == "专题组"
|
||||
assert db.setting is not None
|
||||
|
||||
await upsert_manual_news_item(db, {"title": "Grouped story", "region": "global"}, group_id=group["id"])
|
||||
renamed = await rename_manual_news_group(db, group["id"], "重命名专题")
|
||||
|
||||
record = next(iter(db.records.values()))
|
||||
assert renamed["name"] == "重命名专题"
|
||||
assert record.location_meta["news_meta"]["manual_group_id"] == group["id"]
|
||||
assert record.location_meta["news_meta"]["manual_group_name"] == "重命名专题"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_list_filters_by_group_id(fake_news_queue):
|
||||
db = _FakeNewsSession()
|
||||
group = await create_manual_news_group(db, "导入组")
|
||||
|
||||
await import_manual_news_items(
|
||||
db,
|
||||
[
|
||||
{"title": "In group", "region": "global"},
|
||||
{"title": "Also in group", "region": "global"},
|
||||
],
|
||||
group_id=group["id"],
|
||||
)
|
||||
await upsert_manual_news_item(db, {"title": "Default group", "region": "global"})
|
||||
|
||||
grouped = await list_news_records(db, page=1, page_size=20, group_id=group["id"])
|
||||
default_group = await list_news_records(db, page=1, page_size=20, group_id=DEFAULT_MANUAL_NEWS_GROUP_ID)
|
||||
|
||||
assert grouped["total"] == 2
|
||||
assert {item["manual_group_id"] for item in grouped["items"]} == {group["id"]}
|
||||
assert default_group["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_news_import_parser_requires_json_array():
|
||||
with pytest.raises(ValueError, match="顶层必须是数组"):
|
||||
await parse_manual_news_import_upload(b'{"title":"not an array"}')
|
||||
@@ -28,7 +28,7 @@ def test_protocol_enum_values_remain_api_compatible() -> None:
|
||||
assert [item.value for item in NewsImportanceLevel] == ["low", "medium", "high", "critical"]
|
||||
assert [item.value for item in BreakingLevel] == ["none", "watch", "breaking", "critical"]
|
||||
assert [item.value for item in BreakingScope] == ["regional", "global"]
|
||||
assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference"]
|
||||
assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference", "manual"]
|
||||
assert [item.value for item in UserRole] == ["viewer", "admin", "super_admin"]
|
||||
assert JobStatus.RUNNING.value == "running"
|
||||
assert PlaygroundMessageKind.THINKING.value == "thinking"
|
||||
|
||||
@@ -40,6 +40,9 @@ def test_gesture_event_serializes_stable_protocol_fields():
|
||||
assert payload["seq"] == 7
|
||||
assert payload["source"] == "motion-agent"
|
||||
assert payload["mode"] == "single"
|
||||
assert payload["protocol_version"] == "motion.v2"
|
||||
assert payload["input_mode"] == "single"
|
||||
assert payload["camera_id"] == "unknown"
|
||||
assert payload["payload"] == {}
|
||||
|
||||
|
||||
@@ -88,8 +91,13 @@ def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
|
||||
assert status["camera_count"] == 1
|
||||
assert status["active_camera_ids"] == ["dry-run:null-camera"]
|
||||
assert status["recognizer"] == "dry-run"
|
||||
assert status["protocol_version"] == "motion.v2"
|
||||
assert status["armed"] is False
|
||||
assert status["paused"] is False
|
||||
assert status["devices_open"] is False
|
||||
assert heartbeat == {
|
||||
"timestamp_ms": 123,
|
||||
"protocol_version": "motion.v2",
|
||||
"source": "motion-agent",
|
||||
"type": "heartbeat",
|
||||
}
|
||||
@@ -109,6 +117,7 @@ def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "skeleton"
|
||||
assert payload["protocol_version"] == "motion.v2"
|
||||
assert payload["matched_gesture"] == "rotate_left"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["camera_id"] == "usb:0"
|
||||
@@ -120,6 +129,25 @@ def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
assert "frame" not in payload
|
||||
|
||||
|
||||
def test_v2_gesture_set_accepts_frontend_motion_gestures():
|
||||
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=0)
|
||||
|
||||
for gesture in [
|
||||
"rotate_up",
|
||||
"rotate_down",
|
||||
"focus_prev",
|
||||
"focus_next",
|
||||
"layer_prev",
|
||||
"layer_next",
|
||||
]:
|
||||
event = state.accept(
|
||||
GestureObservation(gesture, confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert event.gesture == gesture
|
||||
|
||||
|
||||
def test_dry_run_recognizer_produces_debug_skeleton():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
@@ -240,3 +268,92 @@ async def test_motion_agent_cli_reports_dependency_error_without_traceback(monke
|
||||
assert exit_code == 2
|
||||
assert "Motion agent failed: missing cv stack" in captured.err
|
||||
assert "Traceback" not in captured.err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_command_updates_control_state():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
armed = await server.handle_command(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "command",
|
||||
"command": "set_armed",
|
||||
"request_id": "req-armed",
|
||||
"payload": {"armed": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
paused = await server.handle_command(
|
||||
{
|
||||
"type": "command",
|
||||
"command": "set_paused",
|
||||
"request_id": "req-paused",
|
||||
"payload": {"paused": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert armed.ok is True
|
||||
assert armed.request_id == "req-armed"
|
||||
assert armed.status["armed"] is True
|
||||
assert paused.ok is True
|
||||
assert paused.status["paused"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_open_devices_command_accepts_dual_mode():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
try:
|
||||
result = await server.handle_command(
|
||||
{
|
||||
"type": "command",
|
||||
"command": "open_devices",
|
||||
"request_id": "req-open",
|
||||
"payload": {"input_mode": "dual_redundant"},
|
||||
}
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.status["input_mode"] == "dual_redundant"
|
||||
assert result.status["active_camera_ids"] == ("dry-run:null-camera",)
|
||||
assert server._recognition_subprocess is not None
|
||||
assert server._recognition_subprocess.returncode is None
|
||||
finally:
|
||||
await server.stop_recognition_subprocess()
|
||||
|
||||
|
||||
def test_motion_agent_dual_fusion_merges_matching_observations():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
server.state.mode = "dual_redundant"
|
||||
|
||||
selected, fusion = server._fuse_observations(
|
||||
[
|
||||
GestureObservation("zoom_in", confidence=0.82, intensity=0.4, camera_id="usb:0"),
|
||||
GestureObservation("zoom_in", confidence=0.86, intensity=0.8, camera_id="usb:1"),
|
||||
]
|
||||
)
|
||||
|
||||
assert selected.gesture == "zoom_in"
|
||||
assert selected.camera_id == "fusion"
|
||||
assert selected.confidence > 0.86
|
||||
assert fusion == {
|
||||
"source_cameras": ["usb:1", "usb:0"],
|
||||
"window_ms": 120,
|
||||
"reason": "matched_observations",
|
||||
}
|
||||
|
||||
|
||||
def test_motion_agent_dual_fusion_suppresses_close_conflict():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True, confidence_threshold=0.7))
|
||||
|
||||
selected, fusion = server._fuse_observations(
|
||||
[
|
||||
GestureObservation("zoom_in", confidence=0.82, intensity=0.5, camera_id="usb:0"),
|
||||
GestureObservation("zoom_out", confidence=0.78, intensity=0.5, camera_id="usb:1"),
|
||||
]
|
||||
)
|
||||
|
||||
assert selected.gesture == "zoom_in"
|
||||
assert selected.confidence == 0
|
||||
assert fusion["reason"] == "conflict_ignored"
|
||||
|
||||
@@ -8,6 +8,22 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.71.0] — 2026-06-11
|
||||
|
||||
Released: 2026-06-11
|
||||
|
||||
### Highlights
|
||||
- 将 Motion Agent 升级为可供 Web/UE 共用的双向控制服务,补齐真实 MediaPipe 识别 worker、设备控制、动作白名单和 WSL 摄像头开箱启动链路。
|
||||
- 新增 Earth 手动新闻内容组、条目、导入与重处理能力,并改进按 locale 和启用来源进行的新闻补充与多样化。
|
||||
- 对齐动捕模式下的 Earth 点击、详情锁定和卫星轨迹交互,同时完善启动脚本、测试 harness 与运维说明。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- Motion Agent 支持命令结果、状态、骨架与手势事件,统一单路/双路输入配置,并随 `planet.sh` 默认启动;仓库内提供 usbipd-win fallback 安装包。
|
||||
- Earth 新闻服务集中处理显示就绪判断和来源多样化,避免存储层与编排层重复筛选;新增手动新闻 API 与回归测试。
|
||||
- 清理 Motion Agent 重复识别执行路径、前端不稳定随机 key 和过时计划描述,补齐 pytest 路径 harness、双语使用手册、快速开始与数据流文档。
|
||||
|
||||
---
|
||||
|
||||
## [0.70.0] — 2026-06-04
|
||||
|
||||
Released: 2026-06-04
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
- [Earth News Cruise Summary Plan](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
|
||||
- [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md)
|
||||
- [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md)
|
||||
- [Motion Agent v2 控制协议与 3D 标定路线](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md)
|
||||
- [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md)
|
||||
- [Earth Vessel Rendering Performance Plan](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
|
||||
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Earth Motion Capture Gesture Control Plan
|
||||
|
||||
> Update: Motion Agent process/device control, UE/Web shared command protocol, dual-camera redundant fusion, and the next 3D calibration route are now tracked in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md). This document remains useful for the original provider split and gesture-control intent.
|
||||
|
||||
## Goal
|
||||
|
||||
为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**状态**:已实现主体交互,并按实测调整。当前浏览器识别保留右手导航、头部切目标、左手上下切动捕图层、双手张开/收拢缩放;双手上举确认暂时关闭。Motion 目标展示已改为 `CruiseSequencer` + `PresentationController` 的 persistent 展示。
|
||||
|
||||
> Update: Agent-side bidirectional commands, UE/Web shared device control, dual-camera redundant fusion, and future calibrated 3D mode are tracked in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md).
|
||||
|
||||
## Summary
|
||||
|
||||
把动捕从“几个单点手势触发函数”升级为一套更像大屏遥控器的交互层:右手负责地球导航,头部负责候选切换,左手上下切换动捕候选图层,双手负责缩放,调试面板支持“只显示骨骼”和暂停匹配。进入动捕模式后,Earth 自动软选中屏幕中心附近的正面可交互目标;确认动作预留为把目标升级为锁定,并用巡航/引导线式详情打开,不再模拟鼠标点击。
|
||||
|
||||
175
docs/plans/motion-agent-v2-control-protocol-plan.md
Normal file
175
docs/plans/motion-agent-v2-control-protocol-plan.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Motion Agent v2 Control Protocol And 3D Calibration Roadmap
|
||||
|
||||
## Summary
|
||||
|
||||
Motion Agent v2 turns the local motion service into a shared WebSocket control plane for the Web Earth page and UE clients. The agent process is still started externally through `planet.sh`, a desktop service, or UE process management. Once the process is running, clients can open cameras, close cameras, switch input mode, arm or pause recognition, and inspect status through the same bidirectional WebSocket protocol.
|
||||
|
||||
This phase implements robust single-camera and dual-camera redundant fusion. True calibrated 3D skeleton reconstruction is deliberately reserved for the v3 calibration phase.
|
||||
|
||||
## v2 Protocol
|
||||
|
||||
The default endpoint remains:
|
||||
|
||||
```text
|
||||
ws://127.0.0.1:8765/ws/gestures
|
||||
```
|
||||
|
||||
The agent emits:
|
||||
|
||||
- `gesture`
|
||||
- `skeleton`
|
||||
- `status`
|
||||
- `heartbeat`
|
||||
- `command_result`
|
||||
|
||||
Clients send:
|
||||
|
||||
- `open_devices`
|
||||
- `close_devices`
|
||||
- `rescan_devices`
|
||||
- `set_armed`
|
||||
- `set_paused`
|
||||
- `set_input_mode`
|
||||
- `set_camera_config`
|
||||
- `set_fusion_config`
|
||||
- `set_debug_options`
|
||||
- `set_enabled_gestures`
|
||||
- `get_status`
|
||||
- `ping`
|
||||
|
||||
All v2 messages include additive compatibility fields such as `protocol_version`, `request_id`, `camera_id`, `input_mode`, and optional `fusion`. Older push-only clients can continue to consume `gesture`, `skeleton`, `status`, and `heartbeat`.
|
||||
|
||||
Example command:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "open_devices",
|
||||
"request_id": "req-001",
|
||||
"payload": {
|
||||
"input_mode": "dual_redundant",
|
||||
"camera_indexes": [0, 1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example gesture whitelist command:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "set_enabled_gestures",
|
||||
"request_id": "earth-motion-enabled-gestures",
|
||||
"payload": {
|
||||
"gestures": ["rotate_left", "rotate_right", "zoom_in", "zoom_out", "confirm"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example result:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command_result",
|
||||
"protocol_version": "motion.v2",
|
||||
"request_id": "req-001",
|
||||
"command": "open_devices",
|
||||
"ok": true,
|
||||
"status": {
|
||||
"armed": false,
|
||||
"paused": false,
|
||||
"input_mode": "dual_redundant",
|
||||
"devices_open": true,
|
||||
"active_camera_ids": ["usb:0", "usb:1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Device And Control State
|
||||
|
||||
Process startup is not a WebSocket feature: the server must exist before a WebSocket client can connect. UE should start the agent as an external process or depend on a system service, then connect to the WebSocket endpoint.
|
||||
|
||||
Device wake and control wake are WebSocket features:
|
||||
|
||||
- `open_devices` opens USB cameras or URL cameras.
|
||||
- `close_devices` releases them.
|
||||
- `set_armed` enables gesture execution.
|
||||
- `set_paused` pauses recognition without closing the connection.
|
||||
|
||||
When `armed=false`, the agent may still emit skeleton and status events, but it does not emit actionable gesture events.
|
||||
|
||||
The Earth settings dialog owns a user-facing gesture whitelist. Unchecked gestures are ignored in the Earth client and are also sent to the Motion Agent through `set_enabled_gestures`, so the server does not broadcast disabled actions to UE/Web consumers. The default whitelist enables the full v2 gesture set; disabling gestures is a local display/control preference and does not change the installed recognition model.
|
||||
|
||||
## Input Modes
|
||||
|
||||
- `single`: one camera.
|
||||
- `dual_redundant`: two or more cameras observe the same gesture. Matching observations in a short window are fused into a higher-confidence event.
|
||||
- `single_fallback`: the primary camera is preferred and a secondary input is used as fallback.
|
||||
- `calibrated_3d`: reserved for v3 and should not be enabled unless a calibration profile exists.
|
||||
|
||||
The v2 dual-camera mode is redundant fusion, not 3D reconstruction. It is meant to improve reliability under occlusion and camera noise without requiring calibration.
|
||||
|
||||
## v3 3D Calibration Roadmap
|
||||
|
||||
The next phase is `Motion Agent v3 3D Calibration`. It upgrades from redundant fusion to calibrated multi-camera skeleton fusion.
|
||||
|
||||
Planned capabilities:
|
||||
|
||||
- Camera intrinsics: focal length, distortion, resolution.
|
||||
- Camera extrinsics: relative position, rotation, and baseline distance.
|
||||
- Calibration workflow: checkerboard, AprilTag, or ArUco board.
|
||||
- Local calibration profile JSON with query, load, reset, and validation commands.
|
||||
- `skeleton_3d` event with world-space joints, confidence, and source cameras.
|
||||
- UE coordinate mapping from Motion Agent coordinates to UE world or widget coordinates.
|
||||
|
||||
Reserved v3 input configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"input_mode": "calibrated_3d",
|
||||
"calibration_profile": "desk-dual-camera-v1"
|
||||
}
|
||||
```
|
||||
|
||||
Reserved v3 event:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "skeleton_3d",
|
||||
"protocol_version": "motion.v3",
|
||||
"profile": "desk-dual-camera-v1",
|
||||
"joints": [
|
||||
{
|
||||
"name": "right_wrist",
|
||||
"x": 0.42,
|
||||
"y": 1.13,
|
||||
"z": 0.76,
|
||||
"confidence": 0.91
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Command/result roundtrip for device open, close, rescan, armed, paused, and status.
|
||||
- Gesture protocol accepts the full Earth v2 gesture set.
|
||||
- Earth settings can disable individual gestures; disabled gestures are ignored locally and filtered server-side through `set_enabled_gestures`.
|
||||
- Motion Agent `status` reports the current enabled gesture list for Web/UE diagnostics.
|
||||
- Single and dual redundant fusion emit compatible gesture payloads.
|
||||
- Conflicting dual-camera observations below the confidence delta are ignored.
|
||||
- Web Earth `motion-agent-provider` can send commands over the same socket it uses for events.
|
||||
- UE mock clients can operate the service without browser-only assumptions.
|
||||
- Dry-run mode can test commands, status, skeleton, and fusion behavior without camera dependencies.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Production recognition uses a real MediaPipe pose pipeline and heuristic gesture recognizer. It still needs environment-specific threshold tuning, camera framing validation, and long-running reliability checks before it can be treated as calibration-free.
|
||||
- Dual-camera v2 does not triangulate 3D joint positions.
|
||||
- `calibrated_3d` is documented as a reserved mode and must not be treated as implemented until v3 lands.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- Implemented: bidirectional command/result protocol, device lifecycle controls, dry-run mode, subprocess recognition worker, MediaPipe pose recognition, gesture whitelist, single-camera mode, dual-redundant/fallback scaffolding, Web client integration, and default `planet.sh` lifecycle integration.
|
||||
- Remaining v2 hardening: tune recognition thresholds across camera placements, exercise UE command/control integration, and run longer soak tests for device reconnect and dual-camera conflicts.
|
||||
- Planned v3: calibrated multi-camera 3D skeleton fusion and Motion Agent-to-UE coordinate calibration.
|
||||
@@ -102,9 +102,9 @@ Responsibilities:
|
||||
- Map `rotate_left`, `rotate_right`, `rotate_up`, `rotate_down`, `zoom_in`, `zoom_out`, `focus_prev`, `focus_next`, `layer_prev`, `layer_next`, and `confirm` to the action entry points exposed by `main.js`.
|
||||
- Parse `skeleton` debug events and dispatch `earth:motion-debug-frame`.
|
||||
|
||||
Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode.
|
||||
Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode. `shared.motionEnabledGestures` stores the user-approved gesture whitelist; the browser filters locally, and Motion Agent mode also synchronizes it through `set_enabled_gestures`.
|
||||
|
||||
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, and `shared.motionDebugSkeletonOnly` in `planet.earth.settings.v2`, and both the switch and provider selector reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
||||
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, `shared.motionDebugSkeletonOnly`, and `shared.motionEnabledGestures` in `planet.earth.settings.v2`; the switch, provider selector, and gesture whitelist reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
||||
|
||||
The Browser Camera provider's gesture pipeline lives in `recognizeGesture()` inside [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js). Detectors are evaluated in this order, first match wins:
|
||||
|
||||
|
||||
@@ -86,6 +86,55 @@ Importance levels are fixed: `low` 0–34, `medium` 35–59, `high` 60–79, and
|
||||
|
||||
`GET /api/v1/earth/news-sources` returns the default or saved configuration. `PUT /api/v1/earth/news-sources` saves it, increments `cache_version`, and clears the process region cache. `POST /api/v1/earth/news-sources/reset` restores defaults. `POST /api/v1/earth/news-sources/test` tests one RSS/Atom/Aggregated source without writing news items.
|
||||
|
||||
## Manual News Content
|
||||
|
||||
Manual news is a content-management path, not an RSS source configuration. The Admin entry is `Earth Content -> News Content`; the left rail groups items by RSS source and manual news group. After opening a manual group, administrators can add one item or upload a JSON array. Manual items are written to `earth_news_items` with `feed_type/source_type = manual`; the display label is “手动添加” / “Manual”. They do not participate in RSS connectivity tests and are not routed through RSS fetching.
|
||||
|
||||
Manual news uses a publish-first, enrich-later flow:
|
||||
|
||||
1. Saving immediately writes the item to `earth_news_items`.
|
||||
2. If no manual coordinates are provided, the selected region anchor is used and `verified=false`.
|
||||
3. If manual coordinates are provided, the item uses `location_source=manual_location` and `verified=true`; later AI enrichment does not overwrite that location.
|
||||
4. Create and reprocess actions enqueue the item for cleanup, translation, classification, importance, Breaking, and target-location inference.
|
||||
5. When enrichment finishes, the same row is updated and Earth receives a news reload / patch so the frontend replaces the item without a manual refresh.
|
||||
|
||||
The Admin API is under `/api/v1/earth/news-items`:
|
||||
|
||||
- `GET /earth/news-groups`: return RSS virtual source groups and manual news groups.
|
||||
- `POST /earth/news-groups`: create a manual news group.
|
||||
- `PUT /earth/news-groups/{group_id}`: rename a manual news group and synchronize metadata for items in that group.
|
||||
- `GET /earth/news-items`: paginated RSS and manual news list, with filters for source type, region, category, and status.
|
||||
- `POST /earth/news-items`: create one manual news item.
|
||||
- `POST /earth/news-items/import`: upload a JSON array; `group_id` selects the current manual news group.
|
||||
- `PUT /earth/news-items/{id}`: edit a manual news item; RSS items are read-only.
|
||||
- `DELETE /earth/news-items/{id}`: delete a manual news item and trigger an Earth news reload.
|
||||
- `POST /earth/news-items/{id}/reprocess`: requeue cleanup, translation, and geolocation.
|
||||
|
||||
The first JSON import format supports arrays only:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "Required title",
|
||||
"summary": "Optional summary",
|
||||
"content": "Optional body",
|
||||
"url": "https://example.com/story",
|
||||
"source": "Manual",
|
||||
"region": "global",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"category": "business",
|
||||
"tags": ["manual", "analysis"],
|
||||
"location": {
|
||||
"label": "Beijing, China",
|
||||
"latitude": 39.9057,
|
||||
"longitude": 116.3913
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Imports deduplicate through a stable `manual:{hash}` ID derived from title, published time, URL, and source. Importing the same manual item again updates the existing row instead of creating a duplicate EarthFeed entry.
|
||||
|
||||
## Feed Query and Category Filtering
|
||||
|
||||
The Web Earth client and UE client both consume `GET /api/v1/news/earth-feed`. The endpoint supports server-side filtering, so clients do not need to fetch the full list and apply the primary category filter locally.
|
||||
@@ -107,6 +156,8 @@ Unknown category or locale values return `422` with the allowed values. The resp
|
||||
|
||||
The Web Earth category chips only store the current browser preference; changing them triggers a new API request. UE should pass its selected categories through the `categories` query parameter and does not need to perform the primary filtering itself.
|
||||
|
||||
When `sources` is omitted, the service layer prefers stories that already have displayable title and summary content for the requested `locale`, supplements candidates from enabled sources, and rotates sources so one source's newest pending items cannot occupy all 12 default slots. An explicit `sources` filter remains precise and does not supplement other sources. The database query layer owns region, category, source, and ordering constraints only; it does not own locale presentation policy.
|
||||
|
||||
Source testing only proves that a specific RSS/Atom/XML feed can be parsed. It does not mean those items have already been written to the news table or are visible in the current region/category view. Saving or resetting news sources increments the configuration version and clears cache; if an enabled feed has no recent stored items, the next `earth-feed` request supplements from RSS so newly enabled sources such as 36Kr and Ebrun are not masked by fresh Google News rows.
|
||||
|
||||
## Breaking News Insertion
|
||||
@@ -163,9 +214,11 @@ Reference links show that they only record a homepage, report page, or future co
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Admin["Admin: Earth Content / News Sources"] --> Source["Source config"]
|
||||
ManualAdmin["Admin: Earth Content / News Content"] --> ManualAPI["/api/v1/earth/news-items"]
|
||||
Source --> Feed["Feed children"]
|
||||
Feed --> ConfigAPI["/api/v1/earth/news-sources"]
|
||||
ConfigAPI --> Config["SystemSetting: earth_news_sources"]
|
||||
ManualAPI --> Store
|
||||
|
||||
Earth["Earth News Panel"] --> NewsAPI["/api/v1/news/earth-feed"]
|
||||
NewsAPI --> Resolver["Source Resolver"]
|
||||
|
||||
@@ -194,6 +194,7 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
|
||||
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. Uploaded images are saved as Earth brand assets and read by the Earth page immediately.
|
||||
- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata.
|
||||
- **TV Livestream**: manages sources shown in the Earth media panel.
|
||||
- **News Content**: browses news grouped by RSS source and manual group. RSS items remain read-only; manual groups support create, JSON import, edit, delete, and reprocess.
|
||||
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action.
|
||||
- **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
|
||||
|
||||
@@ -298,7 +299,7 @@ Adopt All is for batch processing the compute-center unresolved queue. It starts
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only / recognized-gesture whitelist, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
|
||||
News categories use the same chip selector as Cruise Modules. They only filter the news panel and news cruise items in the current browser; they do not affect layers, TV, data points, basemap, boundaries, collector jobs, or admin news-source configuration.
|
||||
|
||||
@@ -336,6 +337,8 @@ Enable via the settings toggle "Motion Debug Mode", or with URL parameter `?moti
|
||||
|
||||
Neither mode uploads camera frames or live gestures; neither reuses the news/RSS aggregation API.
|
||||
|
||||
Recognized Gestures can disable rotation, zoom, focus switching, layer switching, or confirmation independently. The browser ignores unchecked actions; when Motion Agent is active, the same whitelist is synchronized through the control protocol.
|
||||
|
||||
Gesture semantics:
|
||||
|
||||
| Event | Effect |
|
||||
|
||||
@@ -244,22 +244,27 @@ The recommended direction is a small platform compatibility layer for port liste
|
||||
|
||||
The production frontend shape is `vite build` static output served by nginx/Caddy or an equivalent HTTP server. Do not use `bun run dev` or `vite preview` in production. The project does not maintain a parallel Webpack build chain; if a future enterprise requirement needs closer Webpack-ecosystem compatibility, run an Rsbuild/Rspack spike first. Electron should only be evaluated when the official target becomes an offline desktop application.
|
||||
|
||||
## Optional Motion Agent Startup
|
||||
## Default Motion Agent Startup
|
||||
|
||||
`planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable.
|
||||
`planet.sh` now starts the local Motion Agent by default during `start` and full `restart`. This makes the Earth page, UE clients, and debug clients able to connect to `ws://127.0.0.1:8765/ws/gestures` immediately. If the machine has no usable camera, implicit default startup falls back to dry-run protocol mode and does not block backend/frontend startup. Explicit Motion Agent startup through `--motion-agent`, camera indexes, camera URLs, or WSL USB options still treats live camera failures as real errors.
|
||||
|
||||
Start it with:
|
||||
To skip Motion Agent for this run:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent
|
||||
./planet.sh start --non-motion-agent
|
||||
./planet.sh restart --non-motion-agent
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
- `--motion-agent` / `-m`: start or restart the Motion Agent for this command.
|
||||
- `--non-motion-agent`: do not start Motion Agent for this `start` or full `restart`.
|
||||
- `--motion-agent` / `-m`: explicitly start or restart the Motion Agent for this command; live camera failures are reported as failures.
|
||||
- `--motion-agent-port <port>`: override the default WebSocket port `8765`.
|
||||
- `--motion-agent-mode <mode>`: choose `auto`, `single`, `dual_redundant`, `single_fallback`, or `calibrated_3d`; `dual` is kept as a compatibility alias for redundant dual-camera mode.
|
||||
- `--motion-agent-camera-indexes <indexes>`: override auto-detected camera indexes, for example `0` or `0,1`. The same can be provided through `MOTION_AGENT_CAMERA_INDEXES=0,1`.
|
||||
- `--motion-agent-camera-urls <urls>`: use RTSP/HTTP camera streams, useful for WSL, phone cameras, or network cameras. The same can be provided through `MOTION_AGENT_CAMERA_URLS=...`.
|
||||
- `--motion-agent-wsl-usbipd`: in WSL, try to attach the single detected Windows USB camera through `usbipd-win`.
|
||||
- `--motion-agent-wsl-usbipd-busid <BUSID>`: in WSL, attach the camera matching a `usbipd list` BUSID; use this when multiple cameras are present.
|
||||
- `--motion-agent-dry-run`: start only the protocol service without opening cameras or loading CV dependencies; useful for Web client debugging.
|
||||
|
||||
Non-dry-run live mode checks `mediapipe` and `opencv-python` before startup. If the current `.venv` is missing them, the script automatically runs:
|
||||
@@ -268,13 +273,17 @@ Non-dry-run live mode checks `mediapipe` and `opencv-python` before startup. If
|
||||
uv add mediapipe opencv-python
|
||||
```
|
||||
|
||||
To disable startup-time auto-install:
|
||||
To disable startup-time Python CV dependency auto-install:
|
||||
|
||||
```bash
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start
|
||||
```
|
||||
|
||||
Live mode auto-detects `/dev/video*` and passes the first two indexes to the Motion Agent. In WSL, Windows cameras usually do not appear as `/dev/video*` automatically. Check available devices first:
|
||||
`./planet.sh init` also performs a WSL host dependency preflight for `usbipd-win`. If `usbipd.exe` is missing, the script first tries `winget install -e --id dorssel.usbipd-win`, then reuses the repository-bundled dorssel.usbipd-win MSI fallback. If that cached MSI is missing or an architecture-specific MSI is needed, it downloads one and requests an Administrator PowerShell installation. This is best-effort: failure prints next steps but does not block normal initialization. Use `./planet.sh init --non-motion-agent` to skip this preflight.
|
||||
|
||||
Live mode auto-detects `/dev/video*`, then prefers an OpenCV probe to keep only indexes that can open and return frames before passing them to the Motion Agent. In WSL/USB camera setups, one camera can expose multiple `/dev/video*` nodes, and some of them are metadata or non-capture nodes; the script skips those unreadable indexes. In WSL, Windows cameras usually do not appear as `/dev/video*` automatically. Check available devices first:
|
||||
|
||||
Live capture defaults to low-latency settings: `640x360` input and roughly `15Hz` recognition events. The worker uses latest-frame reader threads and keeps only the newest frame from each camera, so a slow MediaPipe frame does not make the recognizer drain stale camera backlog. The skeleton debug stream is disabled by default and is only sent at roughly `8Hz` while the Earth motion debug panel is open, so normal gesture control is not slowed down by debug data. Status events report both capture FPS and recognition FPS to separate camera throughput issues from recognition cost.
|
||||
|
||||
```bash
|
||||
ls /dev/video*
|
||||
@@ -292,20 +301,42 @@ In WSL, the more general path is to connect a phone or network camera through an
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
|
||||
```
|
||||
|
||||
If WSL has no `/dev/video*` and no `--motion-agent-camera-urls` is provided, live startup stops and prints guidance instead of silently falling back to dry-run. Choose one of:
|
||||
To use a Windows USB camera directly from WSL, let the script call `usbipd-win`. This is opt-in because an attached camera is usually temporarily unavailable to Windows apps while WSL owns it.
|
||||
|
||||
When there is only one camera:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
```
|
||||
|
||||
When there are multiple cameras, inspect the BUSID first and pass it explicitly:
|
||||
|
||||
```bash
|
||||
usbipd.exe list
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd-busid 3-2
|
||||
```
|
||||
|
||||
If `usbipd attach` says the device is not shared or bound, the script tries to open an Administrator PowerShell to run `usbipd bind`, then retries attach. If UAC is canceled or automatic bind fails, run this manually from an Administrator PowerShell:
|
||||
|
||||
```powershell
|
||||
usbipd bind --busid 3-2
|
||||
usbipd attach --wsl --busid 3-2
|
||||
```
|
||||
|
||||
If WSL has no `/dev/video*` and no `--motion-agent-camera-urls` is provided, implicit default startup falls back to dry-run. Explicit live startup stops and prints guidance. Choose one of:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://<phone-ip>:8080/video
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
./planet.sh start --motion-agent --motion-agent-dry-run
|
||||
```
|
||||
|
||||
Automatic dry-run fallback only happens when `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` is explicitly set.
|
||||
For explicit live startup, automatic dry-run fallback only happens when `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` is set.
|
||||
|
||||
Environment-variable startup is also supported:
|
||||
`--non-motion-agent` is the command-level opt-out. Environment variables can still tune how the service starts:
|
||||
|
||||
```bash
|
||||
PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 ./planet.sh start
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
@@ -39,7 +39,7 @@ flowchart TB
|
||||
VesselRaw["AIS / BarentsWatch"] --> VesselDerived["vessel_static / vessel_position"]
|
||||
VesselDerived --> VesselLayer["vessels layer"]
|
||||
Interactables["earth_interactables"] --> InteractableLayer["interactables layer"]
|
||||
NewsRaw["RSS / Live / News"] --> NewsItems["earth_news_items"]
|
||||
NewsRaw["RSS / Manual News / Live"] --> NewsItems["earth_news_items"]
|
||||
NewsItems --> NewsLayer["news / media layer"]
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ flowchart TB
|
||||
| BGP context | Collectors, anomalies, incidents, route events, and regional context | `ris_live_bgp`, `bgpstream_bgp`, prefix geography sources | `bgp_observations`, `bgp_anomalies`, `bgp_incidents`, `bgp_collector_locations` | `bgp` | `clear_then_reload` |
|
||||
| Vessels | AIS vessels, positions, tracks, legend, and source health | AIS sources, `barentswatch_vessels` | `vessel_static`, `vessel_position`, `ais_raw_observations`, `ais_source_health` | `vessels` | `clear_then_reload` |
|
||||
| Interactables | Generic surface icons, manual objects, and future small layers | `earth_interactables` | None | `interactables` | `delta` |
|
||||
| News and media | Earth news, live streams, cruise summaries, and situation content | news sources | `earth_news_items` | `news` / `media` | `reload` |
|
||||
| News and media | Earth news, live streams, cruise summaries, and situation content | RSS news sources, manual news, live streams | `earth_news_items` | `news` / `media` | `reload` |
|
||||
|
||||
## Satellites
|
||||
|
||||
@@ -141,12 +141,12 @@ Vessel data shows AIS vessels, navigation state, vessel-type legend, and source
|
||||
|
||||
News and media support the Earth news ticker, live stream panel, news cruise, and situation summaries. They are content refresh paths rather than stable geographic object layers, so they default to `reload`.
|
||||
|
||||
- **Collection entry**: RSS, live streams, news sources.
|
||||
- **Fact table**: news source rows in `collected_data`.
|
||||
- **Collection entry**: RSS news sources, manual news from `Earth Content -> News Content`, and live streams.
|
||||
- **Fact table**: news source rows in `collected_data`; manual news writes directly to `earth_news_items` and marks the content source with `feed_type/source_type=manual`.
|
||||
- **Derived table**: `earth_news_items`.
|
||||
- **API**: news, live stream, and media content APIs.
|
||||
- **API**: `/api/v1/news/earth-feed` reads `earth_news_items`; the Admin API `/api/v1/earth/news-items` supports manual create, JSON import, edit, delete, and reprocess.
|
||||
- **Delete semantics**: deleting news sources or `earth_news_items` broadcasts `news` / `media` reload; empty responses hide the corresponding content.
|
||||
- **Common failure**: the live panel shows stale content. Usually the media component ignored the layer update or the content API cache was not invalidated.
|
||||
- **Common failure**: a newly saved manual item may initially show source text or a region anchor; this is the normal publish-first enrichment window. If it never updates, check the `earth_news_enrichment` queue, AI / Web Search configuration, and `enrichment_status`. If the live panel shows stale content, the media component likely ignored the layer update or the content API cache was not invalidated.
|
||||
|
||||
## Adding a New Layer
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Once in, verify:
|
||||
- Search finds cables, satellites, compute centers, BGP events
|
||||
- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth
|
||||
- Mouse drag, wheel zoom, and the zoom percentage indicator work
|
||||
- The settings panel can switch rotate / cruise / motion modes; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
|
||||
- The settings panel can switch rotate / cruise / motion modes; motion settings can select the input source and allowed gestures; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
|
||||
|
||||
## 5. Recover a Lost Password
|
||||
|
||||
|
||||
@@ -109,9 +109,9 @@ Earth 设置面板现在按 `data-settings-tab` 和 `data-settings-tab-panel`
|
||||
- 将 `rotate_left`、`rotate_right`、`rotate_up`、`rotate_down`、`zoom_in`、`zoom_out`、`focus_prev`、`focus_next`、`layer_prev`、`layer_next`、`confirm` 映射到 `main.js` 暴露的动作入口。
|
||||
- 解析 `skeleton` 调试事件并派发 `earth:motion-debug-frame`。
|
||||
|
||||
动作捕捉识别可以在浏览器本地执行,也可以在本地 Agent 中执行,但两者都不会把实时视频帧发给 SaaS 云端。`main.js` 暴露旋转、缩放、目标切换、图层切换和确认入口,并通过 `window.__planetEarth.motion` 提供调试入口。默认只有 URL 参数 `?motion=1`、本地存储 `planet-earth-motion-control-enabled=true`,或 Earth 设置中的“动捕调试模式”打开时才启动当前 provider。
|
||||
动作捕捉识别可以在浏览器本地执行,也可以在本地 Agent 中执行,但两者都不会把实时视频帧发给 SaaS 云端。`main.js` 暴露旋转、缩放、目标切换、图层切换和确认入口,并通过 `window.__planetEarth.motion` 提供调试入口。默认只有 URL 参数 `?motion=1`、本地存储 `planet-earth-motion-control-enabled=true`,或 Earth 设置中的“动捕调试模式”打开时才启动当前 provider。`shared.motionEnabledGestures` 保存用户允许识别的动作;浏览器端先过滤,Motion Agent 模式还会通过 `set_enabled_gestures` 同步给服务端。
|
||||
|
||||
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2` 的 `shared.motionDebugEnabled`、`shared.motionProvider` 与 `shared.motionDebugSkeletonOnly`,switch 和输入源控件都预留 `data-gatekeeper-permission="earth.motion_debug"`。
|
||||
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2` 的 `shared.motionDebugEnabled`、`shared.motionProvider`、`shared.motionDebugSkeletonOnly` 与 `shared.motionEnabledGestures`,switch、输入源和动作白名单控件都预留 `data-gatekeeper-permission="earth.motion_debug"`。
|
||||
|
||||
Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js) 的 `recognizeGesture()`,按以下顺序匹配,前者命中即返回:
|
||||
|
||||
|
||||
@@ -86,6 +86,55 @@ Earth 态势新闻使用 `/api/v1/news/earth-feed` 输出给前端。新闻源
|
||||
|
||||
`GET /api/v1/earth/news-sources` 返回默认或已保存配置。`PUT /api/v1/earth/news-sources` 保存配置并递增 `cache_version`,同时清理进程内 region cache。`POST /api/v1/earth/news-sources/reset` 恢复默认源。`POST /api/v1/earth/news-sources/test` 只测试单个 RSS/Atom/Aggregated 源,不写入新闻表。
|
||||
|
||||
## 手动新闻内容
|
||||
|
||||
手动新闻是内容管理能力,不是 RSS 源配置。Admin 入口是 `智能星球内容 -> 新闻内容`,左栏按 RSS 来源和手动新闻组聚合;进入手动组详情后可以按条添加新闻或上传 JSON 数组批量导入。手动新闻写入 `earth_news_items`,并标记 `feed_type/source_type = manual`;前台展示文案是“手动添加”。它不参与 RSS 连通性测试,也不会进入 RSS 抓取流程。
|
||||
|
||||
手动新闻采用“先展示,再精修”的策略:
|
||||
|
||||
1. 保存后立即写入 `earth_news_items`。
|
||||
2. 没有人工坐标时使用所选区域锚点,`verified=false`。
|
||||
3. 有人工坐标时使用 `location_source=manual_location`,`verified=true`,后续 AI 精修不会覆盖该坐标。
|
||||
4. 创建或重新处理后进入新闻增强队列,后台补清洗、翻译、分类、重要度、Breaking 和目标位置推断。
|
||||
5. 精修完成后更新同一条新闻,并通过 Earth news reload / patch 让前端无感替换。
|
||||
|
||||
后台接口挂在 `/api/v1/earth/news-items`:
|
||||
|
||||
- `GET /earth/news-groups`:返回 RSS 虚拟来源组和手动新闻组。
|
||||
- `POST /earth/news-groups`:新建手动新闻组。
|
||||
- `PUT /earth/news-groups/{group_id}`:重命名手动新闻组,并同步组内新闻 meta。
|
||||
- `GET /earth/news-items`:分页查询 RSS 与手动新闻,支持来源类型、区域、类型和状态过滤。
|
||||
- `POST /earth/news-items`:新增一条手动新闻。
|
||||
- `POST /earth/news-items/import`:上传 JSON 数组批量导入,`group_id` 指定当前手动新闻组。
|
||||
- `PUT /earth/news-items/{id}`:编辑手动新闻;RSS 新闻只读。
|
||||
- `DELETE /earth/news-items/{id}`:删除手动新闻,并触发 Earth 新闻重载。
|
||||
- `POST /earth/news-items/{id}/reprocess`:重新进入清洗、翻译和定位队列。
|
||||
|
||||
JSON 导入首版只支持数组:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "必填标题",
|
||||
"summary": "可选摘要",
|
||||
"content": "可选正文",
|
||||
"url": "https://example.com/story",
|
||||
"source": "手动添加",
|
||||
"region": "global",
|
||||
"published_at": "2026-05-15T03:00:00Z",
|
||||
"category": "business",
|
||||
"tags": ["manual", "analysis"],
|
||||
"location": {
|
||||
"label": "北京市, 中国",
|
||||
"latitude": 39.9057,
|
||||
"longitude": 116.3913
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
重复导入使用稳定 ID 去重,ID 由标题、发布时间、URL 和来源生成,格式为 `manual:{hash}`。同一条手动新闻再次导入会更新原记录,不会重复出现在 EarthFeed。
|
||||
|
||||
## Feed 查询与类型过滤
|
||||
|
||||
星球端和 UE 端统一使用 `GET /api/v1/news/earth-feed` 获取新闻。接口支持服务端过滤,不要求客户端拿全量列表后自行筛选。
|
||||
@@ -107,6 +156,8 @@ GET /api/v1/news/earth-feed?region=global&categories=business,ecommerce&locale=z
|
||||
|
||||
Web 星球端的新闻类型按钮只保存当前浏览器的显示偏好;偏好变化后会重新请求接口。UE 端应直接把类型选择拼到 `categories` 参数里,不需要再做主过滤。
|
||||
|
||||
未指定 `sources` 时,服务层会优先选择当前 `locale` 已有可展示标题和摘要的新闻,并从当前启用来源补齐候选后做来源轮转,避免一个来源的最新待处理条目占满默认 12 条。指定 `sources` 时保持精确来源过滤,不做跨来源补齐。数据库查询层只负责区域、类型、来源和排序条件,不包含语言展示策略。
|
||||
|
||||
源测试只证明当前 RSS/Atom/XML 能解析到条目,不等于这些条目已经入库展示。展示链路还会检查区域、类型过滤和数据库新鲜度。保存或重置新闻源会递增配置版本并清理缓存;如果当前启用的 Feed 子项在库里没有近期条目,下一次 `earth-feed` 请求会补抓,避免新启用的 36氪、亿邦被旧 Google News 缓存挡住。
|
||||
|
||||
## Breaking News 插队
|
||||
@@ -163,9 +214,11 @@ Admin 入口是 `Earth 内容 -> 新闻源`。界面不是整包 JSON 编辑,
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Admin["Admin: Earth 内容 / 新闻源"] --> Source["Source 配置"]
|
||||
ManualAdmin["Admin: Earth 内容 / 新闻内容"] --> ManualAPI["/api/v1/earth/news-items"]
|
||||
Source --> Feed["Feed 子项"]
|
||||
Feed --> ConfigAPI["/api/v1/earth/news-sources"]
|
||||
ConfigAPI --> Config["SystemSetting: earth_news_sources"]
|
||||
ManualAPI --> Store
|
||||
|
||||
Earth["Earth 新闻面板"] --> NewsAPI["/api/v1/news/earth-feed"]
|
||||
NewsAPI --> Resolver["Source Resolver"]
|
||||
|
||||
@@ -193,6 +193,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为智能星球品牌资产并立即供智能星球页面读取。
|
||||
- **关于**:维护智能星球设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
|
||||
- **电视直播**:维护智能星球媒体面板里的直播源。
|
||||
- **新闻内容**:按 RSS 来源和手动新闻组查看新闻。RSS 新闻保持只读;手动新闻组可以新增、批量导入 JSON、编辑、删除和重新处理。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
@@ -297,7 +298,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
### 设置
|
||||
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼 / 识别动作白名单、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
|
||||
新闻类型使用与巡航模块一致的标签选择器,只筛选当前浏览器里的新闻面板和新闻巡航条目,不影响图层、TV、数据点、底图、边界、采集任务或后台新闻源配置。
|
||||
|
||||
@@ -335,6 +336,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。
|
||||
|
||||
“识别动作”可以单独关闭旋转、缩放、焦点切换、图层切换或确认手势。浏览器端会忽略未勾选动作;使用 Motion Agent 时,同一白名单也会通过控制协议同步给 Agent。
|
||||
|
||||
手势语义:
|
||||
|
||||
| 手势事件 | 作用 |
|
||||
|
||||
@@ -258,22 +258,27 @@ HTTP 健康检查统一使用 `curl -fsS --max-time`。因此 `/health` 返回 4
|
||||
|
||||
前端生产形态是 `vite build` 生成静态资源,再由 nginx/Caddy 等 HTTP 服务器托管。不要在生产中使用 `bun run dev` 或 `vite preview`。当前不维护 Webpack 双构建链;如果未来需要评估更企业化的构建生态,优先做 Rsbuild/Rspack spike。Electron 仅在正式目标变成离线桌面软件时再单独评估。
|
||||
|
||||
## Motion Agent 可选启动
|
||||
## Motion Agent 默认启动
|
||||
|
||||
`planet.sh` 现在可以管理本地动作捕捉 Agent,但默认不会启动它,避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。
|
||||
`planet.sh` 现在默认随 `start` 和全量 `restart` 启动本地 Motion Agent。这样星球端、UE 或调试客户端可以直接连接 `ws://127.0.0.1:8765/ws/gestures`。如果当前机器没有可用摄像头,默认隐式启动会降级为 dry-run 协议服务,不会阻断后端/前端启动;只有显式传入 `--motion-agent`、摄像头 index、摄像头 URL 或 WSL USB 参数时,live 模式缺摄像头才会硬失败。
|
||||
|
||||
启动方式:
|
||||
如果本次不需要 Motion Agent:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent
|
||||
./planet.sh start --non-motion-agent
|
||||
./planet.sh restart --non-motion-agent
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
- `--motion-agent` / `-m`:随本次启动或重启拉起 Motion Agent。
|
||||
- `--non-motion-agent`:本次启动或全量重启不拉起 Motion Agent。
|
||||
- `--motion-agent` / `-m`:显式要求本次启动或重启拉起 Motion Agent;此时 live 摄像头失败会作为错误反馈。
|
||||
- `--motion-agent-port <端口>`:覆盖默认 WebSocket 端口 `8765`。
|
||||
- `--motion-agent-mode <模式>`:指定输入模式,可选 `auto`、`single`、`dual_redundant`、`single_fallback`、`calibrated_3d`;`dual` 作为兼容别名会进入双路冗余。
|
||||
- `--motion-agent-camera-indexes <indexes>`:覆盖自动发现的摄像头 index,例如 `0` 或 `0,1`。也可以用环境变量 `MOTION_AGENT_CAMERA_INDEXES=0,1`。
|
||||
- `--motion-agent-camera-urls <urls>`:使用 RTSP/HTTP 摄像头流,适合 WSL、手机摄像头或网络摄像头。也可以用环境变量 `MOTION_AGENT_CAMERA_URLS=...`。
|
||||
- `--motion-agent-wsl-usbipd`:在 WSL 中尝试通过 `usbipd-win` 自动把唯一的 Windows USB 摄像头透传到 Linux。
|
||||
- `--motion-agent-wsl-usbipd-busid <BUSID>`:在 WSL 中指定 `usbipd list` 里的摄像头 BUSID 后透传,适合多摄像头设备。
|
||||
- `--motion-agent-dry-run`:不打开摄像头、不加载 CV 依赖,只启动协议服务,适合调试 Web 端连接。
|
||||
|
||||
非 dry-run 的 live 模式会在启动前检查 `mediapipe` 和 `opencv-python`。如果当前 `.venv` 缺包,脚本会自动执行:
|
||||
@@ -282,13 +287,17 @@ HTTP 健康检查统一使用 `curl -fsS --max-time`。因此 `/health` 返回 4
|
||||
uv add mediapipe opencv-python
|
||||
```
|
||||
|
||||
如需禁止启动时自动安装,可设置:
|
||||
如需禁止启动时自动安装 Python CV 依赖,可设置:
|
||||
|
||||
```bash
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start
|
||||
```
|
||||
|
||||
live 模式会自动寻找 `/dev/video*`,优先取前两个 index 传给 Motion Agent。在 WSL 中,Windows 摄像头通常不会自动出现在 `/dev/video*`。可先用下面命令看设备:
|
||||
`./planet.sh init` 会在 WSL 中预检查 `usbipd-win`。如果没有 `usbipd.exe`,脚本会先尝试 `winget install -e --id dorssel.usbipd-win`,失败后复用仓库内置的 dorssel.usbipd-win MSI fallback;如果缓存缺失或需要其他架构版本,再下载 MSI 并请求管理员 PowerShell 安装。该步骤是 best-effort:失败会提示后续处理方式,但不会阻断普通初始化。可通过 `./planet.sh init --non-motion-agent` 跳过该预检。
|
||||
|
||||
live 模式会自动寻找 `/dev/video*`,并优先用 OpenCV 实测过滤出真正能打开并读帧的 index,再传给 Motion Agent。在 WSL/USB 摄像头场景中,一个摄像头可能暴露多个 `/dev/video*` 节点,其中部分是 metadata 或非采集节点,脚本会跳过这类不可读 index。在 WSL 中,Windows 摄像头通常不会自动出现在 `/dev/video*`。可先用下面命令看设备:
|
||||
|
||||
默认 live 采集使用低延迟参数:`640x360` 输入、约 `15Hz` 识别事件;worker 内部用 latest-frame 读帧线程,只保留每路摄像头的最新帧,避免 MediaPipe 慢帧时继续排队识别旧画面。骨架调试流默认关闭,只在星球端打开动捕调试面板时按约 `8Hz` 推送,避免日常手势控制被调试数据拖慢。状态事件会同时上报采集 FPS 与识别 FPS,方便区分摄像头掉帧和识别耗时。
|
||||
|
||||
```bash
|
||||
ls /dev/video*
|
||||
@@ -306,20 +315,42 @@ WSL 下更通用的方式是把手机摄像头或网络摄像头以 RTSP/HTTP
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
|
||||
```
|
||||
|
||||
如果 WSL 中没有发现 `/dev/video*`,且没有提供 `--motion-agent-camera-urls`,脚本会停止 live 启动并提示处理方式,不会自动降级为 dry-run。可选处理:
|
||||
如果希望直接使用 Windows USB 摄像头,可以让脚本调用 `usbipd-win` 透传。该能力是显式开启的,因为摄像头附加到 WSL 期间通常会从 Windows 应用中暂时断开。
|
||||
|
||||
只有一个摄像头时:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
```
|
||||
|
||||
多个摄像头时,先查看 BUSID,再指定设备:
|
||||
|
||||
```bash
|
||||
usbipd.exe list
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd-busid 3-2
|
||||
```
|
||||
|
||||
如果 `usbipd attach` 提示设备未共享或未绑定,脚本会尝试弹出 Windows 管理员 PowerShell 自动执行 `usbipd bind`,然后重试 attach。若 UAC 被取消或自动 bind 失败,可在 Windows 管理员 PowerShell 中手动执行:
|
||||
|
||||
```powershell
|
||||
usbipd bind --busid 3-2
|
||||
usbipd attach --wsl --busid 3-2
|
||||
```
|
||||
|
||||
如果 WSL 中没有发现 `/dev/video*`,且没有提供 `--motion-agent-camera-urls`,默认隐式启动会降级为 dry-run。显式 live 启动会停止并提示处理方式。可选处理:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://<手机IP>:8080/video
|
||||
./planet.sh start --motion-agent --motion-agent-wsl-usbipd
|
||||
./planet.sh start --motion-agent --motion-agent-dry-run
|
||||
```
|
||||
|
||||
只有显式设置 `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` 时,WSL 无摄像头才会自动降级。
|
||||
显式 live 启动时,只有设置 `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1`,WSL 无摄像头才会自动降级。
|
||||
|
||||
也可以用环境变量启用:
|
||||
`--non-motion-agent` 是命令级跳过入口。环境变量仍可调整服务启动方式:
|
||||
|
||||
```bash
|
||||
PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 ./planet.sh start
|
||||
```
|
||||
|
||||
日志入口:
|
||||
|
||||
@@ -39,7 +39,7 @@ flowchart TB
|
||||
VesselRaw["AIS / BarentsWatch"] --> VesselDerived["vessel_static / vessel_position"]
|
||||
VesselDerived --> VesselLayer["vessels 图层"]
|
||||
Interactables["earth_interactables"] --> InteractableLayer["interactables 图层"]
|
||||
NewsRaw["RSS / Live / News"] --> NewsItems["earth_news_items"]
|
||||
NewsRaw["RSS / 手动新闻 / Live"] --> NewsItems["earth_news_items"]
|
||||
NewsItems --> NewsLayer["news / media 图层"]
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ flowchart TB
|
||||
| BGP 态势 | 展示观测站、异常事件、路由事件和区域态势 | `ris_live_bgp`、`bgpstream_bgp`、prefix geography sources | `bgp_observations`、`bgp_anomalies`、`bgp_incidents`、`bgp_collector_locations` | `bgp` | `clear_then_reload` |
|
||||
| 船舶 | 展示 AIS 船只、位置、轨迹和源健康 | AIS sources、`barentswatch_vessels` | `vessel_static`、`vessel_position`、`ais_raw_observations`、`ais_source_health` | `vessels` | `clear_then_reload` |
|
||||
| 可交互对象 | 支撑通用地表图标、人工点位和未来扩展对象 | `earth_interactables` | 无 | `interactables` | `delta` |
|
||||
| 新闻与媒体 | 支撑 Earth 新闻、直播和巡航摘要 | news sources | `earth_news_items` | `news` / `media` | `reload` |
|
||||
| 新闻与媒体 | 支撑 Earth 新闻、直播和巡航摘要 | RSS news sources、手动新闻、直播源 | `earth_news_items` | `news` / `media` | `reload` |
|
||||
|
||||
## 卫星链路
|
||||
|
||||
@@ -141,12 +141,12 @@ sequenceDiagram
|
||||
|
||||
新闻与媒体数据用于 Earth 顶部新闻条、直播面板、新闻巡航和态势摘要。它们的视觉状态比地理对象更偏内容刷新,因此默认使用 `reload`。
|
||||
|
||||
- **采集入口**:RSS、直播源、新闻 source。
|
||||
- **事实表**:新闻 source 的 `collected_data`。
|
||||
- **采集入口**:RSS 新闻源、`智能星球内容 -> 新闻内容` 的手动新闻、直播源。
|
||||
- **事实表**:新闻 source 的 `collected_data`;手动新闻直接写入 `earth_news_items`,并以 `feed_type/source_type=manual` 标记内容来源。
|
||||
- **派生表**:`earth_news_items`。
|
||||
- **接口**:新闻、直播和媒体 visualization / content API。
|
||||
- **接口**:`/api/v1/news/earth-feed` 读取 `earth_news_items`;后台管理接口 `/api/v1/earth/news-items` 支持新增、JSON 导入、编辑、删除和重新处理手动新闻。
|
||||
- **删除语义**:删除新闻 source 或 `earth_news_items` 后广播 `news` / `media` reload;前端重拉后列表为空即隐藏对应内容。
|
||||
- **常见异常**:直播面板仍显示旧内容,通常是媒体组件本地状态没有响应 layer update,或内容接口缓存未失效。
|
||||
- **常见异常**:手动新闻保存后只显示原文或大区锚点是正常的“先展示再精修”窗口;若长期不更新,应检查 `earth_news_enrichment` 队列、AI / Web Search 配置和 `enrichment_status`。直播面板仍显示旧内容,通常是媒体组件本地状态没有响应 layer update,或内容接口缓存未失效。
|
||||
|
||||
## 扩展新图层
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
|
||||
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在智能星球上预览
|
||||
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;动捕设置可以选择输入源和允许识别的动作;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
|
||||
## 5. 找回密码
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.70.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.71.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.71.0` | feature | `dev` | `pending` | Motion Agent 升级为 Web/UE 共用双向控制与真实识别服务,新增 Earth 手动新闻工作流、来源多样化,并完善启动/测试 harness 与双语文档 |
|
||||
| `0.70.0` | feature | `dev` | `pending` | 新增后端枚举契约治理、Earth 新闻分类/Breaking 链路和船只当前状态快照,清理错误视口刷新逻辑并同步双语文档 |
|
||||
| `0.69.0` | feature | `dev` | `pending` | 新增 Earth 新闻源治理、新闻类型服务端过滤、观测日志 fingerprint 聚合和 TV/HLS 播放恢复改进 |
|
||||
| `0.68.1` | bugfix | `dev` | `pending` | 修复 CelesTrak fallback group/cache 恢复链路,并让数据源任务日志可按 task_id / datasource_id 搜索 |
|
||||
|
||||
BIN
downloads/usbipd-win/usbipd-win-5.3.0.msi
Normal file
BIN
downloads/usbipd-win/usbipd-win-5.3.0.msi
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.70.0",
|
||||
"version": "0.71.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1173,6 +1173,28 @@
|
||||
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="earth-mobile-settings-card earth-mobile-settings-card--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">识别动作</span>
|
||||
<span class="earth-mobile-settings-subtitle">关闭后不会触发对应星球控制</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-chip-grid" role="group" aria-label="移动端选择动捕识别动作">
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_left" aria-pressed="true">左旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_right" aria-pressed="true">右旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_up" aria-pressed="true">上旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_down" aria-pressed="true">下旋</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="zoom_in" aria-pressed="true">放大</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="zoom_out" aria-pressed="true">缩小</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="focus_prev" aria-pressed="true">上个焦点</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="focus_next" aria-pressed="true">下个焦点</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="layer_prev" aria-pressed="true">上一图层</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="layer_next" aria-pressed="true">下一图层</button>
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="confirm" aria-pressed="true">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
|
||||
<div class="earth-mobile-settings-title">快捷键</div>
|
||||
@@ -1705,6 +1727,28 @@
|
||||
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="earth-settings-item earth-settings-item--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">识别动作</span>
|
||||
<span class="earth-settings-item-subtitle">未勾选的动作不会触发星球端控制;Motion Agent 会同步过滤这些动作</span>
|
||||
</div>
|
||||
<div class="earth-settings-chip-grid" role="group" aria-label="选择动捕识别动作">
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_left" aria-pressed="true">左旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_right" aria-pressed="true">右旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_up" aria-pressed="true">上旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_down" aria-pressed="true">下旋</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="zoom_in" aria-pressed="true">放大</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="zoom_out" aria-pressed="true">缩小</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="focus_prev" aria-pressed="true">上个焦点</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="focus_next" aria-pressed="true">下个焦点</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="layer_prev" aria-pressed="true">上一图层</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="layer_next" aria-pressed="true">下一图层</button>
|
||||
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="confirm" aria-pressed="true">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>
|
||||
|
||||
104
frontend/public/earth/js/controls.js
vendored
104
frontend/public/earth/js/controls.js
vendored
@@ -111,6 +111,7 @@ import {
|
||||
} from "./layer-button-state.js";
|
||||
import {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
MOTION_GESTURES,
|
||||
normalizeMotionProvider,
|
||||
} from "./motion-protocol.js";
|
||||
|
||||
@@ -125,6 +126,7 @@ let autoRotationSpeed = CONFIG.rotationSpeed;
|
||||
let motionDebugEnabled = false;
|
||||
let motionProvider = DEFAULT_MOTION_PROVIDER;
|
||||
let motionDebugSkeletonOnly = false;
|
||||
let motionEnabledGestures = [];
|
||||
let activeCamera = null;
|
||||
let settingsApplyPromise = Promise.resolve();
|
||||
let boundaryBuildPollTimer = null;
|
||||
@@ -159,7 +161,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
|
||||
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||
const EARTH_SETTINGS_VERSION = 16;
|
||||
const EARTH_SETTINGS_VERSION = 17;
|
||||
const GRID_LINES_DEFAULT_VERSION = 3;
|
||||
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
|
||||
const MEDIA_PANEL_DEFAULT_VERSION = 5;
|
||||
@@ -173,6 +175,22 @@ const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
|
||||
const CRUISE_QUEUE_DEFAULT_VERSION = 14;
|
||||
const AUTO_ROTATION_SPEED_DEFAULT_VERSION = 15;
|
||||
const NEWS_CATEGORY_FILTERS_DEFAULT_VERSION = 16;
|
||||
const MOTION_GESTURES_DEFAULT_VERSION = 17;
|
||||
const MOTION_GESTURE_DEFINITIONS = [
|
||||
{ id: "rotate_left", label: "左旋" },
|
||||
{ id: "rotate_right", label: "右旋" },
|
||||
{ id: "rotate_up", label: "上旋" },
|
||||
{ id: "rotate_down", label: "下旋" },
|
||||
{ id: "zoom_in", label: "放大" },
|
||||
{ id: "zoom_out", label: "缩小" },
|
||||
{ id: "focus_prev", label: "上个焦点" },
|
||||
{ id: "focus_next", label: "下个焦点" },
|
||||
{ id: "layer_prev", label: "上一图层" },
|
||||
{ id: "layer_next", label: "下一图层" },
|
||||
{ id: "confirm", label: "确认" },
|
||||
];
|
||||
const DEFAULT_MOTION_ENABLED_GESTURES = MOTION_GESTURE_DEFINITIONS.map((item) => item.id);
|
||||
motionEnabledGestures = [...DEFAULT_MOTION_ENABLED_GESTURES];
|
||||
const DEFAULT_NEWS_CATEGORY_FILTERS = {
|
||||
politics: true,
|
||||
business: true,
|
||||
@@ -1427,6 +1445,7 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
motionDebugEnabled,
|
||||
motionProvider,
|
||||
motionDebugSkeletonOnly,
|
||||
motionEnabledGestures: getMotionEnabledGestures(),
|
||||
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
|
||||
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
|
||||
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
|
||||
@@ -1489,6 +1508,7 @@ function cloneEarthSettings(settings) {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
),
|
||||
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
|
||||
motionEnabledGestures: normalizeMotionEnabledGestures(settings.shared.motionEnabledGestures),
|
||||
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
|
||||
satelliteIdleBreathingEnabled:
|
||||
settings.shared.satelliteIdleBreathingEnabled !== false,
|
||||
@@ -1621,6 +1641,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
typeof sharedSettings?.motionDebugSkeletonOnly === "boolean"
|
||||
? sharedSettings.motionDebugSkeletonOnly
|
||||
: defaults.shared.motionDebugSkeletonOnly;
|
||||
const nextMotionEnabledGestures =
|
||||
(rawSettings?.version || 0) >= MOTION_GESTURES_DEFAULT_VERSION
|
||||
? normalizeMotionEnabledGestures(sharedSettings?.motionEnabledGestures)
|
||||
: normalizeMotionEnabledGestures(defaults.shared.motionEnabledGestures);
|
||||
const nextMediaPanelActiveTab =
|
||||
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
|
||||
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
|
||||
@@ -1689,6 +1713,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
motionDebugEnabled: nextMotionDebugEnabled,
|
||||
motionProvider: nextMotionProvider,
|
||||
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
|
||||
motionEnabledGestures: nextMotionEnabledGestures,
|
||||
mediaPanelActiveTab: nextMediaPanelActiveTab,
|
||||
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
|
||||
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
|
||||
@@ -1746,6 +1771,17 @@ function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly
|
||||
});
|
||||
}
|
||||
|
||||
function syncMotionGestureControls() {
|
||||
const enabledGestures = new Set(getMotionEnabledGestures());
|
||||
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const gesture = button.dataset.motionGestureToggle || "";
|
||||
const active = enabledGestures.has(gesture);
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchMotionSettingsChange() {
|
||||
const effectiveDebugEnabled =
|
||||
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
|
||||
@@ -1756,6 +1792,7 @@ function dispatchMotionSettingsChange() {
|
||||
preferredEnabled: motionDebugEnabled,
|
||||
provider: motionProvider,
|
||||
skeletonOnly: motionDebugSkeletonOnly,
|
||||
enabledGestures: getMotionEnabledGestures(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1866,6 +1903,18 @@ function normalizeCruiseModules(nextModules) {
|
||||
: [...DEFAULT_CRUISE_MODULES];
|
||||
}
|
||||
|
||||
function normalizeMotionEnabledGestures(nextGestures) {
|
||||
const sourceGestures = Array.isArray(nextGestures)
|
||||
? nextGestures
|
||||
: DEFAULT_MOTION_ENABLED_GESTURES;
|
||||
const normalizedGestures = Array.from(
|
||||
new Set(sourceGestures.filter((gesture) => MOTION_GESTURES.has(gesture))),
|
||||
);
|
||||
return normalizedGestures.length > 0
|
||||
? normalizedGestures
|
||||
: [...DEFAULT_MOTION_ENABLED_GESTURES];
|
||||
}
|
||||
|
||||
function normalizeCruiseQueueMode(mode) {
|
||||
return ALLOWED_CRUISE_QUEUE_MODES.has(mode) ? mode : DEFAULT_CRUISE_QUEUE_MODE;
|
||||
}
|
||||
@@ -2396,6 +2445,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
|
||||
persist: false,
|
||||
suppressStatus: true,
|
||||
});
|
||||
setMotionEnabledGestures(settings.shared.motionEnabledGestures, {
|
||||
persist: false,
|
||||
suppressStatus: true,
|
||||
});
|
||||
setActiveTVTab(settings.shared.mediaPanelActiveTab);
|
||||
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
|
||||
renderShortcutSettings();
|
||||
@@ -2428,6 +2481,10 @@ export function getMotionDebugSkeletonOnly() {
|
||||
return motionDebugSkeletonOnly;
|
||||
}
|
||||
|
||||
export function getMotionEnabledGestures() {
|
||||
return normalizeMotionEnabledGestures(motionEnabledGestures);
|
||||
}
|
||||
|
||||
export function setMotionDebugEnabled(
|
||||
nextEnabled,
|
||||
{ persist = true, suppressStatus = false } = {},
|
||||
@@ -2517,6 +2574,34 @@ export function setMotionDebugSkeletonOnly(
|
||||
return motionDebugSkeletonOnly;
|
||||
}
|
||||
|
||||
export function setMotionEnabledGestures(
|
||||
nextGestures,
|
||||
{ persist = true, suppressStatus = false } = {},
|
||||
) {
|
||||
const normalized = normalizeMotionEnabledGestures(nextGestures);
|
||||
const previous = getMotionEnabledGestures();
|
||||
const changed =
|
||||
normalized.length !== previous.length ||
|
||||
normalized.some((gesture, index) => previous[index] !== gesture);
|
||||
|
||||
motionEnabledGestures = normalized;
|
||||
syncMotionGestureControls();
|
||||
|
||||
ensureMutableEarthSettingsState();
|
||||
earthSettingsState.shared.motionEnabledGestures = [...motionEnabledGestures];
|
||||
|
||||
if (changed) {
|
||||
dispatchMotionSettingsChange();
|
||||
}
|
||||
if (persist) {
|
||||
persistEarthSettings();
|
||||
}
|
||||
if (!suppressStatus && changed) {
|
||||
showStatusMessage("动捕识别动作已更新", "info");
|
||||
}
|
||||
return getMotionEnabledGestures();
|
||||
}
|
||||
|
||||
export async function applyDeferredLayerVisibilitySettings(options = {}) {
|
||||
const layerVisibility = deferredLayerVisibilitySettings;
|
||||
deferredLayerVisibilitySettings = null;
|
||||
@@ -3886,6 +3971,7 @@ function integrateMotionSettingsIntoRuntime() {
|
||||
markRuntimeModeSection("[data-auto-rotation-speed-slider]", ROTATION_MODE.ROTATE);
|
||||
markRuntimeModeSection("[data-motion-debug-toggle]", ROTATION_MODE.MOTION);
|
||||
markRuntimeModeSection("[data-motion-provider]", ROTATION_MODE.MOTION);
|
||||
markRuntimeModeSection("[data-motion-gesture-toggle]", ROTATION_MODE.MOTION);
|
||||
syncRuntimeModeSections();
|
||||
}
|
||||
|
||||
@@ -4434,6 +4520,21 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((motionGestureButton) => {
|
||||
if (!(motionGestureButton instanceof HTMLButtonElement)) return;
|
||||
bindListener(motionGestureButton, "click", () => {
|
||||
const gesture = motionGestureButton.dataset.motionGestureToggle;
|
||||
if (!gesture) return;
|
||||
const nextGestures = new Set(getMotionEnabledGestures());
|
||||
if (nextGestures.has(gesture)) {
|
||||
nextGestures.delete(gesture);
|
||||
} else {
|
||||
nextGestures.add(gesture);
|
||||
}
|
||||
setMotionEnabledGestures(Array.from(nextGestures));
|
||||
});
|
||||
});
|
||||
|
||||
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
|
||||
bindListener(mobileSettingsReset, "click", () => {
|
||||
resetEarthSettings();
|
||||
@@ -4454,6 +4555,7 @@ function setupSettingsControls() {
|
||||
syncMotionDebugToggle(motionDebugEnabled);
|
||||
syncMotionProviderControls(motionProvider);
|
||||
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
|
||||
syncMotionGestureControls();
|
||||
void setupBoundaryPrecisionControls();
|
||||
}
|
||||
|
||||
|
||||
@@ -165,3 +165,21 @@ export function applyEarthInteractableEvent(earth, payload = {}) {
|
||||
export function getEarthInteractableMarkers() {
|
||||
return earthInteractableLayer.getMarkers();
|
||||
}
|
||||
|
||||
export function getEarthInteractablePointerIntersections(options = {}) {
|
||||
return earthInteractableLayer.getPointerIntersections(options);
|
||||
}
|
||||
|
||||
export function setEarthInteractableMarkerState(marker, state = "normal") {
|
||||
earthInteractableLayer.setMarkerState(marker, state);
|
||||
}
|
||||
|
||||
export function clearEarthInteractableSelection() {
|
||||
earthInteractableLayer.getMarkers().forEach((marker) => {
|
||||
earthInteractableLayer.setMarkerState(marker, "normal");
|
||||
});
|
||||
}
|
||||
|
||||
export function updateEarthInteractableVisualState(focusType, focusObject, camera) {
|
||||
earthInteractableLayer.updateVisualState(focusType, focusObject, camera);
|
||||
}
|
||||
|
||||
@@ -1677,6 +1677,22 @@ const CARD_CONFIG = {
|
||||
{ key: 'length', label: '船长', unit: 'm' },
|
||||
{ key: 'received_at', label: '更新时间' }
|
||||
]
|
||||
},
|
||||
earth_interactable: {
|
||||
icon: '📍',
|
||||
title: '交互点详情',
|
||||
className: 'earth_interactable',
|
||||
fields: [
|
||||
{ key: 'label', label: '名称' },
|
||||
{ key: 'kind', label: '类型' },
|
||||
{ key: 'id', label: '标识' },
|
||||
{ key: 'latitude', label: '纬度' },
|
||||
{ key: 'longitude', label: '经度' },
|
||||
{ key: 'description', label: '说明' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -210,9 +210,13 @@ import {
|
||||
} from "./vessels.js";
|
||||
import {
|
||||
applyEarthInteractableEvent,
|
||||
clearEarthInteractableSelection,
|
||||
clearEarthInteractables,
|
||||
getEarthInteractablePointerIntersections as getEarthInteractableIconPointerIntersections,
|
||||
loadEarthInteractables,
|
||||
refreshEarthInteractables,
|
||||
setEarthInteractableMarkerState,
|
||||
updateEarthInteractableVisualState,
|
||||
} from "./earth-interactables.js";
|
||||
import {
|
||||
setupControls,
|
||||
@@ -235,6 +239,7 @@ import {
|
||||
getDayNightEnabled,
|
||||
getMotionDebugEnabled,
|
||||
getMotionDebugSkeletonOnly,
|
||||
getMotionEnabledGestures,
|
||||
getMotionProvider,
|
||||
getVisibleMotionLayerDefinitions,
|
||||
setMotionDebugEnabled,
|
||||
@@ -317,6 +322,7 @@ let hoveredCable = null;
|
||||
let hoveredBGP = null;
|
||||
let hoveredComputeCenter = null;
|
||||
let hoveredVessel = null;
|
||||
let hoveredEarthInteractable = null;
|
||||
let hoveredSatellite = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let lockedSatellite = null;
|
||||
@@ -618,6 +624,7 @@ export function clearLockedObject() {
|
||||
clearBGPSelection();
|
||||
clearComputeCenterSelection();
|
||||
clearVesselSelection();
|
||||
clearEarthInteractableSelection();
|
||||
clearRelatedSatelliteHighlights();
|
||||
setSatelliteRingState(null, "none", null);
|
||||
clearRuntimeSelection();
|
||||
@@ -692,9 +699,11 @@ function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
resetTransientVesselStates();
|
||||
resetTransientEarthInteractableStates();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
hoveredVessel = null;
|
||||
hoveredEarthInteractable = null;
|
||||
|
||||
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
|
||||
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
|
||||
@@ -720,6 +729,17 @@ function getVesselPointerIntersections() {
|
||||
});
|
||||
}
|
||||
|
||||
function getEarthInteractablePointerIntersections() {
|
||||
const earth = getEarth();
|
||||
return getEarthInteractableIconPointerIntersections({
|
||||
earth,
|
||||
camera,
|
||||
pointer: interactionMouse,
|
||||
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
|
||||
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
function getBGPEventPointerIntersections() {
|
||||
const earth = getEarth();
|
||||
return getBGPEventIconPointerIntersections({
|
||||
@@ -824,6 +844,30 @@ function isSameVessel(marker1, marker2) {
|
||||
return Boolean(marker1 && marker2 && marker1.userData?.mmsi === marker2.userData?.mmsi);
|
||||
}
|
||||
|
||||
function isSameEarthInteractable(marker1, marker2) {
|
||||
return Boolean(marker1 && marker2 && marker1.userData?.id === marker2.userData?.id);
|
||||
}
|
||||
|
||||
function resetTransientEarthInteractableStates() {
|
||||
if (hoveredEarthInteractable && hoveredEarthInteractable !== lockedObject) {
|
||||
setEarthInteractableMarkerState(hoveredEarthInteractable, "normal");
|
||||
}
|
||||
hoveredEarthInteractable = null;
|
||||
}
|
||||
|
||||
function applyEarthInteractableHoverState(marker) {
|
||||
if (isSameEarthInteractable(hoveredEarthInteractable, marker)) return;
|
||||
resetTransientEarthInteractableStates();
|
||||
if (!marker) {
|
||||
hoveredEarthInteractable = null;
|
||||
return;
|
||||
}
|
||||
hoveredEarthInteractable = marker;
|
||||
if (marker !== lockedObject) {
|
||||
setEarthInteractableMarkerState(marker, "hover");
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstObjectIntersection(intersections) {
|
||||
return intersections.find((hit) => !hit?.cluster && hit?.object)?.object || null;
|
||||
}
|
||||
@@ -943,7 +987,8 @@ function getMotionCandidateScreenCoords(candidate) {
|
||||
candidate.type === "bgp" ||
|
||||
candidate.type === "bgp_collector" ||
|
||||
candidate.type === "compute_center" ||
|
||||
candidate.type === "vessel"
|
||||
candidate.type === "vessel" ||
|
||||
candidate.type === "earth_interactable"
|
||||
) {
|
||||
return getMarkerMotionScreenPoint(candidate.object) || candidate.screen || getMotionCenterScreenPoint();
|
||||
}
|
||||
@@ -971,7 +1016,8 @@ function getMotionCandidateAnchor(candidate) {
|
||||
candidate.type === "bgp" ||
|
||||
candidate.type === "bgp_collector" ||
|
||||
candidate.type === "compute_center" ||
|
||||
candidate.type === "vessel"
|
||||
candidate.type === "vessel" ||
|
||||
candidate.type === "earth_interactable"
|
||||
) {
|
||||
return getMotionAnchorRectFromCenter(
|
||||
getMarkerMotionScreenPoint(candidate.object) || candidate.screen,
|
||||
@@ -1021,6 +1067,10 @@ function getMotionIconCandidates() {
|
||||
candidates.push({ type: "vessel", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||||
});
|
||||
}
|
||||
getEarthInteractableIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||||
if (hit.cluster || !hit.object) return;
|
||||
candidates.push({ type: "earth_interactable", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -1096,7 +1146,7 @@ function collectMotionFocusCandidates() {
|
||||
...getMotionSatelliteCandidates(),
|
||||
...getMotionCableCandidates(),
|
||||
]
|
||||
.filter((candidate) => !layerId || getMotionCandidateLayerId(candidate) === layerId)
|
||||
.filter((candidate) => candidate.type === "earth_interactable" || !layerId || getMotionCandidateLayerId(candidate) === layerId)
|
||||
.sort((left, right) => left.distancePxSq - right.distancePxSq);
|
||||
}
|
||||
|
||||
@@ -1107,6 +1157,7 @@ function getMotionCandidateLayerId(candidate) {
|
||||
if (candidate.type === "bgp" || candidate.type === "bgp_collector") return "bgp";
|
||||
if (candidate.type === "compute_center") return "computeCenters";
|
||||
if (candidate.type === "vessel") return "vessels";
|
||||
if (candidate.type === "earth_interactable") return "interactables";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1308,6 +1359,7 @@ function clearMotionFocusVisual() {
|
||||
if (candidate.type === "bgp" || candidate.type === "bgp_collector") resetTransientBGPStates();
|
||||
if (candidate.type === "compute_center") resetTransientComputeCenterStates();
|
||||
if (candidate.type === "vessel") resetTransientVesselStates();
|
||||
if (candidate.type === "earth_interactable") resetTransientEarthInteractableStates();
|
||||
if (candidate.type === "satellite" && candidate.index !== lockedSatelliteIndex) {
|
||||
setSatelliteRingState(candidate.index, "none", null);
|
||||
setHoveredSatelliteIndex(null);
|
||||
@@ -1331,6 +1383,8 @@ function applyMotionFocusVisual(candidate) {
|
||||
applyComputeCenterHoverState(candidate.object);
|
||||
} else if (candidate.type === "vessel") {
|
||||
applyVesselHoverState(candidate.object);
|
||||
} else if (candidate.type === "earth_interactable") {
|
||||
applyEarthInteractableHoverState(candidate.object);
|
||||
} else if (candidate.type === "satellite") {
|
||||
hoveredSatelliteIndex = candidate.index;
|
||||
hoveredSatellite = { properties: candidate.object };
|
||||
@@ -1467,6 +1521,8 @@ function showMotionCandidateInfo(candidate, options = {}) {
|
||||
showComputeCenterInfo(candidate.object, options);
|
||||
} else if (candidate.type === "vessel") {
|
||||
showVesselInfo(candidate.object, options);
|
||||
} else if (candidate.type === "earth_interactable") {
|
||||
showEarthInteractableInfo(candidate.object, options);
|
||||
} else if (candidate.type === "cable") {
|
||||
showCableInfo(candidate.object, options);
|
||||
} else if (candidate.type === "satellite") {
|
||||
@@ -1481,6 +1537,7 @@ function getMotionCandidateFocusCoords(candidate) {
|
||||
if (candidate.type === "bgp" || candidate.type === "bgp_collector") return getBGPFocusCoords(candidate.object);
|
||||
if (candidate.type === "compute_center") return getComputeCenterFocusCoords(candidate.object);
|
||||
if (candidate.type === "vessel") return getVesselFocusCoords(candidate.object);
|
||||
if (candidate.type === "earth_interactable") return getEarthInteractableFocusCoords(candidate.object);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1491,17 +1548,58 @@ function getMotionCandidateLabel(candidate) {
|
||||
candidate.object?.userData?.name ||
|
||||
candidate.object?.userData?.collector ||
|
||||
candidate.object?.userData?.mmsi ||
|
||||
candidate.object?.userData?.label ||
|
||||
candidate.object?.userData?.id ||
|
||||
"目标"
|
||||
);
|
||||
}
|
||||
|
||||
function confirmMotionCandidate(candidate) {
|
||||
function getMotionCandidateFromCruiseItem(item) {
|
||||
const payload = item?.payload || null;
|
||||
if (!payload) return null;
|
||||
if (
|
||||
payload.type === "bgp" ||
|
||||
payload.type === "bgp_collector" ||
|
||||
payload.type === "compute_center" ||
|
||||
payload.type === "vessel" ||
|
||||
payload.type === "earth_interactable" ||
|
||||
payload.type === "cable" ||
|
||||
payload.type === "satellite"
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const userDataType = payload?.userData?.type;
|
||||
if (userDataType === "bgp" || userDataType === "bgp_collector" || userDataType === "earth_interactable") {
|
||||
return { type: userDataType, object: payload };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCurrentMotionConfirmationCandidate() {
|
||||
return (
|
||||
motionFocusedCandidate ||
|
||||
getMotionCandidateFromCruiseItem(motionCruiseSequencer?.getCurrentItem?.()) ||
|
||||
getMotionCandidateFromCruiseItem(motionSharedCruiseSequencer?.getCurrentItem?.()) ||
|
||||
motionFocusCandidates[motionFocusIndex] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function dismissMotionPresentationsForConfirmation() {
|
||||
motionCruiseSequencer?.stop?.({ preservePresentation: false });
|
||||
motionSharedCruiseSequencer?.stop?.({ preservePresentation: false });
|
||||
presentationController?.dismiss?.("motion_confirm");
|
||||
}
|
||||
|
||||
function confirmMotionCandidate(candidate, { showMotionStatus = true } = {}) {
|
||||
const earth = getEarth();
|
||||
if (!candidate || !earth) return false;
|
||||
interruptCruisePresentation();
|
||||
dismissMotionPresentationsForConfirmation();
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
const sourceCoords = candidate.screen || getMotionCenterScreenPoint();
|
||||
const sourceCoords = candidate.screen || getMotionCandidateScreenCoords(candidate) || getMotionCenterScreenPoint();
|
||||
|
||||
if (candidate.type === "bgp") {
|
||||
const marker = candidate.object;
|
||||
@@ -1538,6 +1636,11 @@ function confirmMotionCandidate(candidate) {
|
||||
showVesselTrack(marker, earth).catch((error) => {
|
||||
console.warn("船只轨迹加载失败:", error);
|
||||
});
|
||||
} else if (candidate.type === "earth_interactable") {
|
||||
const marker = candidate.object;
|
||||
setEarthInteractableMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "earth_interactable";
|
||||
} else if (candidate.type === "cable") {
|
||||
const cable = candidate.object;
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
|
||||
@@ -1562,8 +1665,16 @@ function confirmMotionCandidate(candidate) {
|
||||
}
|
||||
|
||||
motionFocusedCandidate = candidate;
|
||||
showMotionCandidateInfo(candidate, {
|
||||
x: sourceCoords.x,
|
||||
y: sourceCoords.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
|
||||
showStatusMessage(`动捕: 已确认${getMotionCandidateLabel(candidate)}`, "info");
|
||||
if (showMotionStatus) {
|
||||
showStatusMessage(`动捕: 已确认${getMotionCandidateLabel(candidate)}`, "info");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1716,6 +1827,28 @@ function showVesselInfo(marker, coords) {
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function showEarthInteractableInfo(marker, coords) {
|
||||
const ud = marker?.userData || {};
|
||||
showInfoCard("earth_interactable", {
|
||||
id: ud.id || "-",
|
||||
label: ud.label || ud.name || "-",
|
||||
kind: ud.kind || "-",
|
||||
latitude: Number.isFinite(Number(ud.latitude)) ? Number(ud.latitude).toFixed(4) : "-",
|
||||
longitude: Number.isFinite(Number(ud.longitude)) ? Number(ud.longitude).toFixed(4) : "-",
|
||||
description: ud.description || ud.summary || "-",
|
||||
source: ud.source || "-",
|
||||
status: ud.status || "-",
|
||||
updated_at: ud.updated_at || ud.updatedAt || "-",
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function getEarthInteractableBriefHtml(marker) {
|
||||
const ud = marker?.userData || {};
|
||||
const name = ud.label || ud.name || "交互点";
|
||||
const kind = ud.kind || "数据点";
|
||||
return `<strong>${name}</strong><br>${kind}`;
|
||||
}
|
||||
|
||||
function getVesselBriefHtml(marker) {
|
||||
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
|
||||
const speed = marker.userData?.sog ?? "-";
|
||||
@@ -1939,6 +2072,13 @@ function getVesselFocusCoords(marker) {
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function getEarthInteractableFocusCoords(marker) {
|
||||
const lat = Number(marker?.userData?.latitude);
|
||||
const lon = Number(marker?.userData?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
|
||||
if (!coords || !camera) return;
|
||||
await focusEarthView(camera, {
|
||||
@@ -4108,6 +4248,8 @@ function setupMotionControl() {
|
||||
onFocus: applyMotionFocus,
|
||||
onLayer: applyMotionLayerSwitch,
|
||||
onStatus: (message, type) => showStatusMessage(message, type),
|
||||
debugSkeleton: motionDebugEnabled,
|
||||
enabledGestures: getMotionEnabledGestures(),
|
||||
});
|
||||
motionControlAdapter.start();
|
||||
}
|
||||
@@ -4157,10 +4299,10 @@ export function applyMotionConfirm() {
|
||||
const releaseGate = beginMotionActionGate("confirm");
|
||||
if (!releaseGate) return false;
|
||||
try {
|
||||
let candidate = motionFocusedCandidate || motionFocusCandidates[motionFocusIndex] || null;
|
||||
let candidate = getCurrentMotionConfirmationCandidate();
|
||||
if (!candidate) {
|
||||
refreshMotionFocusCandidates({ force: true });
|
||||
candidate = motionFocusedCandidate || motionFocusCandidates[motionFocusIndex] || null;
|
||||
candidate = getCurrentMotionConfirmationCandidate();
|
||||
}
|
||||
if (candidate && confirmMotionCandidate(candidate)) {
|
||||
return true;
|
||||
@@ -5146,8 +5288,21 @@ function setupEventListeners() {
|
||||
const handleComputeCenterUnresolvedCountChange = (event) => {
|
||||
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
|
||||
};
|
||||
const handleMotionDebugModeChange = () => {
|
||||
setupMotionControl();
|
||||
const handleMotionDebugModeChange = (event) => {
|
||||
const motionModeActive = getRotationMode() === ROTATION_MODE.MOTION && getAutoRotate();
|
||||
const nextEnabled = motionModeActive && Boolean(event?.detail?.enabled);
|
||||
setMotionDebugPanelVisible(nextEnabled);
|
||||
setMotionDebugPanelSkeletonOnly(Boolean(event?.detail?.skeletonOnly));
|
||||
motionControlAdapter?.sendCommand?.(
|
||||
"set_debug_options",
|
||||
{ skeleton: nextEnabled },
|
||||
"earth-motion-debug-toggle",
|
||||
);
|
||||
motionControlAdapter?.setEnabledGestures?.(
|
||||
Array.isArray(event?.detail?.enabledGestures)
|
||||
? event.detail.enabledGestures
|
||||
: getMotionEnabledGestures(),
|
||||
);
|
||||
};
|
||||
const handleMotionDebugClose = () => {
|
||||
setMotionDebugEnabled(false);
|
||||
@@ -5333,6 +5488,7 @@ function onMouseMove(event) {
|
||||
: [];
|
||||
const vesselPick = getVesselHoverIntersections();
|
||||
const vesselIntersects = vesselPick.intersects;
|
||||
const earthInteractableIntersects = getEarthInteractablePointerIntersections();
|
||||
|
||||
let hoveredSat = null;
|
||||
let hoveredSatIndexFromIntersect = null;
|
||||
@@ -5351,6 +5507,7 @@ function onMouseMove(event) {
|
||||
bgpAnomalyIntersects,
|
||||
bgpCollectorIntersects,
|
||||
computeCenterIntersects,
|
||||
earthInteractableIntersects,
|
||||
);
|
||||
|
||||
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
|
||||
@@ -5360,6 +5517,7 @@ function onMouseMove(event) {
|
||||
const hoveredComputeCenterMarker = getFirstObjectIntersection(computeCenterIntersects);
|
||||
const hoveredVesselMarker =
|
||||
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
|
||||
const hoveredEarthInteractableMarker = getFirstObjectIntersection(earthInteractableIntersects);
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
@@ -5402,6 +5560,12 @@ function onMouseMove(event) {
|
||||
) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
if (
|
||||
hoveredEarthInteractable &&
|
||||
!isSameEarthInteractable(hoveredEarthInteractable, hoveredEarthInteractableMarker)
|
||||
) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
|
||||
if (
|
||||
hoveredCable &&
|
||||
@@ -5472,6 +5636,17 @@ function onMouseMove(event) {
|
||||
getVesselBriefHtml(hoveredVesselMarker),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (
|
||||
hoveredEarthInteractableMarker &&
|
||||
lockedObjectType !== "earth_interactable"
|
||||
) {
|
||||
applyEarthInteractableHoverState(hoveredEarthInteractableMarker);
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
getEarthInteractableBriefHtml(hoveredEarthInteractableMarker),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||
const cable = cableIntersects[0].object;
|
||||
hoveredCable = cable;
|
||||
@@ -5504,6 +5679,8 @@ function onMouseMove(event) {
|
||||
applyComputeCenterHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "vessel" && lockedObject) {
|
||||
applyVesselHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "earth_interactable" && lockedObject) {
|
||||
applyEarthInteractableHoverState(lockedObject);
|
||||
} else if (
|
||||
!lockedObjectType &&
|
||||
!isCruisePresentationPinned() &&
|
||||
@@ -5515,6 +5692,7 @@ function onMouseMove(event) {
|
||||
if (vesselPick.checked) {
|
||||
resetTransientVesselStates();
|
||||
}
|
||||
resetTransientEarthInteractableStates();
|
||||
hideInfoCard();
|
||||
}
|
||||
|
||||
@@ -5736,6 +5914,7 @@ function onClick(event) {
|
||||
const vesselIntersects = getShowVessels()
|
||||
? getVesselPointerIntersections()
|
||||
: [];
|
||||
const earthInteractableIntersects = getEarthInteractablePointerIntersections();
|
||||
const satIntersects = getSatellitePointerIntersections(event);
|
||||
|
||||
const clickedBGPMarker = getShowBGP()
|
||||
@@ -5745,6 +5924,7 @@ function onClick(event) {
|
||||
const clickedVesselMarker = vesselIntersects.length > 0
|
||||
? vesselIntersects[0].object
|
||||
: null;
|
||||
const clickedEarthInteractableMarker = getFirstObjectIntersection(earthInteractableIntersects);
|
||||
|
||||
if (clickedBGPMarker?.userData?.type === "bgp") {
|
||||
interruptCruisePresentation();
|
||||
@@ -5832,6 +6012,22 @@ function onClick(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedEarthInteractableMarker?.userData?.type === "earth_interactable") {
|
||||
const clickedMarker = clickedEarthInteractableMarker;
|
||||
if (confirmMotionCandidate({
|
||||
type: "earth_interactable",
|
||||
object: clickedMarker,
|
||||
screen: { x: event.clientX, y: event.clientY },
|
||||
distancePxSq: 0,
|
||||
}, { showMotionStatus: false })) {
|
||||
showStatusMessage(
|
||||
`已选择交互点: ${clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标"}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cableIntersects.length > 0 && getShowCables()) {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
@@ -5876,31 +6072,13 @@ function onClick(event) {
|
||||
|
||||
const sat = selectSatellite(selectedIndex);
|
||||
if (!sat?.properties) return;
|
||||
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
lockedObject = sat;
|
||||
lockedObjectType = "satellite";
|
||||
lockedSatellite = sat;
|
||||
lockedSatelliteIndex = selectedIndex;
|
||||
setLockedSatelliteIndex(selectedIndex);
|
||||
showPredictedOrbit(sat);
|
||||
setAutoRotate(false);
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (satPositions?.[selectedIndex]) {
|
||||
setSatelliteRingState(
|
||||
selectedIndex,
|
||||
"locked",
|
||||
satPositions[selectedIndex].current,
|
||||
);
|
||||
if (hoveredSatelliteIndex === selectedIndex) {
|
||||
setHoveredSatelliteIndex(selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
|
||||
confirmMotionCandidate({
|
||||
type: "satellite",
|
||||
index: selectedIndex,
|
||||
object: sat,
|
||||
screen: { x: event.clientX, y: event.clientY },
|
||||
distancePxSq: 0,
|
||||
}, { showMotionStatus: false });
|
||||
showStatusMessage("已选择: " + sat.properties.name, "info");
|
||||
return;
|
||||
}
|
||||
@@ -5986,6 +6164,7 @@ function animate() {
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
|
||||
updateVesselVisualState(lockedObjectType, lockedObject, camera);
|
||||
updateEarthInteractableVisualState(lockedObjectType, lockedObject, camera);
|
||||
|
||||
if (lockedObjectType === "cable" && lockedObject) {
|
||||
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
|
||||
|
||||
@@ -10,12 +10,15 @@ export function createMotionAgentProvider(options = {}) {
|
||||
onMessage = () => {},
|
||||
onState = () => {},
|
||||
onStatus = () => {},
|
||||
debugSkeleton = false,
|
||||
enabledGestures = [],
|
||||
} = options;
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
let disposed = false;
|
||||
let connected = false;
|
||||
let requestSeq = 0;
|
||||
|
||||
function emitState(detail = {}) {
|
||||
onState({
|
||||
@@ -55,6 +58,24 @@ export function createMotionAgentProvider(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommand(command, payload = {}, requestId = null) {
|
||||
if (!socket || !connected || socket.readyState !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "motion_agent_not_connected",
|
||||
requestId: requestId || null,
|
||||
};
|
||||
}
|
||||
const nextRequestId = requestId || `earth-motion-${Date.now()}-${++requestSeq}`;
|
||||
socket.send(JSON.stringify({
|
||||
type: "command",
|
||||
command,
|
||||
request_id: nextRequestId,
|
||||
payload,
|
||||
}));
|
||||
return { ok: true, requestId: nextRequestId };
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (disposed || socket) return;
|
||||
if (!WebSocketCtor) {
|
||||
@@ -73,6 +94,15 @@ export function createMotionAgentProvider(options = {}) {
|
||||
socket.onopen = () => {
|
||||
connected = true;
|
||||
emitState({ connected: true });
|
||||
sendCommand("set_debug_options", { skeleton: Boolean(debugSkeleton) }, "earth-motion-debug-on-connect");
|
||||
if (Array.isArray(enabledGestures) && enabledGestures.length > 0) {
|
||||
sendCommand(
|
||||
"set_enabled_gestures",
|
||||
{ gestures: enabledGestures },
|
||||
"earth-motion-enabled-gestures-on-connect",
|
||||
);
|
||||
}
|
||||
sendCommand("set_armed", { armed: true }, "earth-motion-armed-on-connect");
|
||||
onStatus("动捕 Agent 已连接", "info");
|
||||
};
|
||||
socket.onmessage = (rawMessage) => onMessage(rawMessage?.data ?? rawMessage);
|
||||
@@ -104,5 +134,8 @@ export function createMotionAgentProvider(options = {}) {
|
||||
isConnected() {
|
||||
return connected;
|
||||
},
|
||||
sendCommand(command, payload = {}, requestId = null) {
|
||||
return sendCommand(command, payload, requestId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
|
||||
import {
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
MOTION_GESTURES,
|
||||
MOTION_PROVIDER_AGENT,
|
||||
normalizeGestureMessage,
|
||||
normalizeMotionProvider,
|
||||
@@ -24,6 +25,7 @@ const DEFAULT_LAYER_COOLDOWN_MS = 1400;
|
||||
const DEFAULT_CONFIRM_COOLDOWN_MS = 1200;
|
||||
const ENABLED_STORAGE_KEY = "planet-earth-motion-control-enabled";
|
||||
const URL_STORAGE_KEY = "planet-earth-motion-control-url";
|
||||
const DEFAULT_ENABLED_GESTURES = Array.from(MOTION_GESTURES);
|
||||
|
||||
const GESTURE_POLICIES = {
|
||||
rotate_left: { group: "rotate_left", cooldownMs: DEFAULT_COOLDOWN_MS },
|
||||
@@ -85,6 +87,8 @@ export function createMotionControlAdapter(options = {}) {
|
||||
minConfidence = DEFAULT_MIN_CONFIDENCE,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
providerFactories = {},
|
||||
debugSkeleton = false,
|
||||
enabledGestures = DEFAULT_ENABLED_GESTURES,
|
||||
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
|
||||
onRotate = () => false,
|
||||
onZoom = () => false,
|
||||
@@ -102,6 +106,7 @@ export function createMotionControlAdapter(options = {}) {
|
||||
let activeProvider = null;
|
||||
let connected = false;
|
||||
let recognitionPaused = false;
|
||||
let enabledGestureSet = normalizeEnabledGestureSet(enabledGestures);
|
||||
const lastHandledByGestureGroup = new Map();
|
||||
|
||||
function emitState(detail) {
|
||||
@@ -120,6 +125,7 @@ export function createMotionControlAdapter(options = {}) {
|
||||
}
|
||||
|
||||
function shouldHandleGesture(event) {
|
||||
if (!enabledGestureSet.has(event?.gesture)) return false;
|
||||
if (!event || event.confidence < minConfidence) return false;
|
||||
const policy = GESTURE_POLICIES[event.gesture] || {
|
||||
group: event.gesture,
|
||||
@@ -208,6 +214,8 @@ export function createMotionControlAdapter(options = {}) {
|
||||
...sharedOptions,
|
||||
url,
|
||||
WebSocketCtor,
|
||||
debugSkeleton,
|
||||
enabledGestures: Array.from(enabledGestureSet),
|
||||
});
|
||||
}
|
||||
return createBrowserCameraProvider(sharedOptions);
|
||||
@@ -242,6 +250,21 @@ export function createMotionControlAdapter(options = {}) {
|
||||
isConnected() {
|
||||
return Boolean(activeProvider?.isConnected?.());
|
||||
},
|
||||
sendCommand(command, payload = {}, requestId = null) {
|
||||
return activeProvider?.sendCommand?.(command, payload, requestId) || {
|
||||
ok: false,
|
||||
error: "motion_provider_commands_unavailable",
|
||||
requestId,
|
||||
};
|
||||
},
|
||||
setEnabledGestures(nextGestures) {
|
||||
enabledGestureSet = normalizeEnabledGestureSet(nextGestures);
|
||||
lastHandledByGestureGroup.clear();
|
||||
activeProvider?.sendCommand?.("set_enabled_gestures", {
|
||||
gestures: Array.from(enabledGestureSet),
|
||||
}, "earth-motion-enabled-gestures");
|
||||
return Array.from(enabledGestureSet);
|
||||
},
|
||||
getProvider() {
|
||||
return selectedProvider;
|
||||
},
|
||||
@@ -253,6 +276,14 @@ export function createMotionControlAdapter(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnabledGestureSet(gestures) {
|
||||
const values = Array.isArray(gestures) ? gestures : DEFAULT_ENABLED_GESTURES;
|
||||
const normalized = values
|
||||
.map((gesture) => String(gesture || "").trim())
|
||||
.filter((gesture) => MOTION_GESTURES.has(gesture));
|
||||
return new Set(normalized.length > 0 ? normalized : DEFAULT_ENABLED_GESTURES);
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_AGENT_URL,
|
||||
DEFAULT_MOTION_PROVIDER,
|
||||
|
||||
@@ -201,6 +201,44 @@ describe("motion-control provider manager", () => {
|
||||
expect(debugEvent?.detail.confidence).toBe(0);
|
||||
});
|
||||
|
||||
test("disabled gestures are ignored and can be re-enabled at runtime", () => {
|
||||
installWindow();
|
||||
const rotations = [];
|
||||
const sentCommands = [];
|
||||
const adapter = createMotionControlAdapter({
|
||||
enabled: true,
|
||||
cooldownMs: 0,
|
||||
enabledGestures: ["zoom_in"],
|
||||
onRotate: (...args) => rotations.push(args),
|
||||
providerFactories: {
|
||||
browser_camera: () => ({
|
||||
start() {
|
||||
return true;
|
||||
},
|
||||
stop() {},
|
||||
isConnected: () => true,
|
||||
sendCommand(command, payload) {
|
||||
sentCommands.push({ command, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
adapter.start();
|
||||
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
|
||||
expect(rotations).toHaveLength(0);
|
||||
|
||||
adapter.setEnabledGestures(["rotate_left"]);
|
||||
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
|
||||
|
||||
expect(rotations).toHaveLength(1);
|
||||
expect(sentCommands.at(-1)).toEqual({
|
||||
command: "set_enabled_gestures",
|
||||
payload: { gestures: ["rotate_left"] },
|
||||
});
|
||||
});
|
||||
|
||||
test("mock vertical gesture and focus gesture use dedicated callbacks", () => {
|
||||
installWindow();
|
||||
const rotations = [];
|
||||
|
||||
@@ -47,6 +47,10 @@ export function normalizeGestureMessage(raw, fallbackSource = "motion-provider")
|
||||
seq: Number(raw.seq || 0),
|
||||
source: raw.source || fallbackSource,
|
||||
mode: raw.mode || "single",
|
||||
protocolVersion: raw.protocol_version || raw.protocolVersion || "motion.v1",
|
||||
cameraId: raw.camera_id || raw.cameraId || "unknown",
|
||||
inputMode: raw.input_mode || raw.inputMode || raw.mode || "single",
|
||||
fusion: raw.fusion && typeof raw.fusion === "object" ? raw.fusion : null,
|
||||
payload: raw.payload && typeof raw.payload === "object" ? raw.payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const SOURCE_TYPE_LABELS = {
|
||||
rss: "RSS",
|
||||
atom: "Atom",
|
||||
aggregated: "Aggregated",
|
||||
manual: "手动添加",
|
||||
reference: "Reference",
|
||||
};
|
||||
|
||||
@@ -162,6 +163,7 @@ export function isRegionalMonitorFeed(feedName) {
|
||||
export function getNewsFetchChannelLabel(feedName, sourceType = "") {
|
||||
const normalized = normalizeText(sourceType).toLowerCase();
|
||||
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return "区域监测";
|
||||
if (normalized === "manual") return "手动添加";
|
||||
if (normalized === "atom") return "单源 Atom";
|
||||
if (normalized === "reference") return "配置保留";
|
||||
return "单源 RSS";
|
||||
|
||||
@@ -68,6 +68,7 @@ const NEWS_CATEGORY_ALIASES = {
|
||||
culture: ["culture", "arts", "entertainment", "文化", "艺术", "娱乐"],
|
||||
other: ["other", "general", "misc", "其他", "综合"],
|
||||
};
|
||||
const NEWS_CATEGORY_KEYS = Object.keys(NEWS_CATEGORY_ALIASES);
|
||||
|
||||
function loadNewsSourceFilters() {
|
||||
try {
|
||||
@@ -459,7 +460,7 @@ function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
|
||||
}
|
||||
|
||||
function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
|
||||
if (!filters || typeof filters !== "object") return [];
|
||||
if (!filters || typeof filters !== "object") return [...NEWS_CATEGORY_KEYS].sort();
|
||||
return Object.entries(filters)
|
||||
.filter(([, enabled]) => enabled !== false)
|
||||
.map(([key]) => key)
|
||||
@@ -469,7 +470,7 @@ function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
|
||||
|
||||
function getNewsCategorySignature(filters = activeNewsCategoryFilters) {
|
||||
const enabled = getEnabledNewsCategoryKeys(filters);
|
||||
const total = Object.keys(NEWS_CATEGORY_ALIASES).length;
|
||||
const total = NEWS_CATEGORY_KEYS.length;
|
||||
if (enabled.length === 0) return "__none__";
|
||||
if (enabled.length === total) return "";
|
||||
return enabled.join(",");
|
||||
@@ -490,7 +491,30 @@ function getEnabledNewsSourceIds(nextPayload = payload) {
|
||||
return available.filter((id) => allowed.has(id));
|
||||
}
|
||||
|
||||
function reconcileNewsSourceFilters(nextPayload = payload) {
|
||||
const available = getAvailableSourceIds(nextPayload);
|
||||
if (!available.length || !Array.isArray(activeNewsSourceFilters)) return;
|
||||
|
||||
const valid = activeNewsSourceFilters.filter((id) => available.includes(id));
|
||||
const changed = valid.length !== activeNewsSourceFilters.length;
|
||||
if (activeNewsSourceFilters.length > 0 && valid.length === 0) {
|
||||
activeNewsSourceFilters = null;
|
||||
persistNewsSourceFilters(null);
|
||||
return;
|
||||
}
|
||||
if (valid.length === available.length) {
|
||||
activeNewsSourceFilters = null;
|
||||
persistNewsSourceFilters(null);
|
||||
return;
|
||||
}
|
||||
if (changed) {
|
||||
activeNewsSourceFilters = valid;
|
||||
persistNewsSourceFilters(valid);
|
||||
}
|
||||
}
|
||||
|
||||
function getNewsSourceSignature(nextPayload = payload) {
|
||||
reconcileNewsSourceFilters(nextPayload);
|
||||
const available = getAvailableSourceIds(nextPayload);
|
||||
const enabled = getEnabledNewsSourceIds(nextPayload);
|
||||
if (available.length > 0 && enabled.length === 0) return "__none__";
|
||||
@@ -520,7 +544,7 @@ function summarizeSelection(enabledCount, totalCount) {
|
||||
|
||||
function syncFilterSummaries(nextPayload = payload) {
|
||||
const categories = getEnabledNewsCategoryKeys();
|
||||
const totalCategories = Object.keys(NEWS_CATEGORY_ALIASES).length;
|
||||
const totalCategories = NEWS_CATEGORY_KEYS.length;
|
||||
const sources = getAvailableSourceIds(nextPayload);
|
||||
const enabledSources = getEnabledNewsSourceIds(nextPayload);
|
||||
document.querySelectorAll('[data-news-filter-summary="category"]').forEach((el) => {
|
||||
@@ -550,7 +574,7 @@ function closeNewsFilterPopover() {
|
||||
|
||||
function renderCategoryFilterChips() {
|
||||
const enabled = new Set(getEnabledNewsCategoryKeys());
|
||||
return Object.keys(NEWS_CATEGORY_ALIASES)
|
||||
return NEWS_CATEGORY_KEYS
|
||||
.map((key) => `
|
||||
<button
|
||||
class="news-filter-chip${enabled.has(key) ? " is-active" : ""}"
|
||||
|
||||
@@ -833,6 +833,7 @@ const fieldLabels: Record<string, string> = {
|
||||
region: '地区',
|
||||
language: '语言',
|
||||
source_type: '播放类型',
|
||||
sourceType: '来源类型',
|
||||
embed_url: '嵌入地址',
|
||||
stream_url: '播放流地址',
|
||||
homepage_url: '主页地址',
|
||||
@@ -870,6 +871,18 @@ const fieldLabels: Record<string, string> = {
|
||||
status: '状态',
|
||||
state: '状态',
|
||||
source: '来源',
|
||||
feed_name: 'Feed 名称',
|
||||
published_at: '发布时间',
|
||||
category: '新闻类型',
|
||||
tags_text: '标签',
|
||||
summary: '摘要',
|
||||
content: '正文',
|
||||
latitude: '纬度',
|
||||
longitude: '经度',
|
||||
location_label: '位置标签',
|
||||
enrichment_status: '处理状态',
|
||||
translated: '已翻译',
|
||||
verified: '已定位',
|
||||
display_name: '显示名称',
|
||||
module: '层级',
|
||||
product: '产品',
|
||||
@@ -1590,7 +1603,7 @@ function newsSourceGroup(source: AnyRecord, healthPayload: AnyRecord = {}): Hier
|
||||
const health: AnyRecord = isObjectRecord(maybeHealth) ? maybeHealth : {}
|
||||
const editor: AnyRecord = newsSourceToEditor(source, health)
|
||||
return {
|
||||
key: `news-source:${text(editor.id, text(editor.name, crypto.randomUUID()))}`,
|
||||
key: `news-source:${text(editor.id, text(editor.name, 'unnamed'))}`,
|
||||
label: pick(editor, ['name', 'id'], '新闻源'),
|
||||
description: [text(editor.source_type, '').toUpperCase(), text(editor.region, ''), text(editor.default_category, ''), text(editor.__healthLabel, '')].filter(Boolean).join(' · '),
|
||||
status: newsSourceStatusLabel(editor),
|
||||
@@ -1658,6 +1671,97 @@ function newsSourceValidationError(source: AnyRecord, existingSources: AnyRecord
|
||||
return ''
|
||||
}
|
||||
|
||||
function newsItemSourceType(item: AnyRecord) {
|
||||
return text(item.source_type || item.feed_type, 'rss').toLowerCase()
|
||||
}
|
||||
|
||||
function newsItemToEditor(item: AnyRecord): AnyRecord {
|
||||
const tags = Array.isArray(item.item_tags)
|
||||
? item.item_tags.map((tag) => String(tag)).filter(Boolean)
|
||||
: Array.isArray(item.tags)
|
||||
? item.tags.map((tag) => String(tag)).filter(Boolean)
|
||||
: []
|
||||
const sourceType = newsItemSourceType(item)
|
||||
const editable = Boolean(item.editable || sourceType === 'manual' || text(item.id, '').startsWith('manual:'))
|
||||
return {
|
||||
...item,
|
||||
title: text(item.title || item.display_title, ''),
|
||||
summary: text(item.summary || item.display_summary, ''),
|
||||
content: text(item.manual_content || item.content, ''),
|
||||
source: text(item.source, editable ? '手动添加' : ''),
|
||||
url: text(item.url, ''),
|
||||
region: text(item.region, 'global'),
|
||||
published_at: text(item.published_at, ''),
|
||||
category: text(item.category, 'other'),
|
||||
tags_text: tags.join(', '),
|
||||
latitude: item.latitude ?? '',
|
||||
longitude: item.longitude ?? '',
|
||||
location_label: text(item.location_label, ''),
|
||||
source_type: sourceType,
|
||||
editable,
|
||||
__module: sourceType === 'manual' ? '手动新闻' : '新闻条目',
|
||||
__status: text(item.status || item.enrichment_status, editable ? 'pending' : ''),
|
||||
__title: pick(item, ['title', 'display_title', 'id'], '新闻条目'),
|
||||
}
|
||||
}
|
||||
|
||||
function newsItemFromEditor(record: AnyRecord) {
|
||||
const next = cleanRecord(record)
|
||||
const latitudeText = text(next.latitude, '').trim()
|
||||
const longitudeText = text(next.longitude, '').trim()
|
||||
const location = latitudeText && longitudeText ? {
|
||||
label: text(next.location_label, ''),
|
||||
latitude: Number(latitudeText),
|
||||
longitude: Number(longitudeText),
|
||||
} : undefined
|
||||
return {
|
||||
title: text(next.title, '').trim(),
|
||||
summary: text(next.summary, '').trim(),
|
||||
content: text(next.content, '').trim(),
|
||||
url: text(next.url, '').trim(),
|
||||
source: text(next.source, '').trim(),
|
||||
region: text(next.region, 'global'),
|
||||
published_at: text(next.published_at, '').trim() || undefined,
|
||||
category: text(next.category, 'other'),
|
||||
tags: text(next.tags_text, '').split(/[,,\n]/).map((item) => item.trim()).filter(Boolean),
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
function newsItemValidationError(payload: AnyRecord) {
|
||||
if (!text(payload.title, '').trim()) return '新闻标题不能为空。'
|
||||
if (payload.location) {
|
||||
const location = payload.location as AnyRecord
|
||||
const latitude = Number(location.latitude)
|
||||
const longitude = Number(location.longitude)
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return '坐标必须是数字。'
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return '坐标超出范围。'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function newsContentGroup(group: AnyRecord): HierarchyGroup {
|
||||
const groupType = text(group.group_type, 'rss')
|
||||
const sourceType = text(group.source_type, groupType)
|
||||
return {
|
||||
key: `news-group:${text(group.id, text(group.name, 'unnamed'))}`,
|
||||
label: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
|
||||
description: [
|
||||
groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
|
||||
sourceType.toUpperCase(),
|
||||
text(group.region, ''),
|
||||
].filter(Boolean).join(' · '),
|
||||
status: group.editable === false ? '只读' : '可编辑',
|
||||
count: Number(group.count || arrayAt(group, 'items').length || 0),
|
||||
record: {
|
||||
...group,
|
||||
__module: groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
|
||||
__status: group.editable === false ? '只读' : '可编辑',
|
||||
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function updateNewsFeedDraft(
|
||||
draft: string,
|
||||
fallback: AnyRecord,
|
||||
@@ -2573,6 +2677,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const [hierarchyDraft, setHierarchyDraft] = useState('')
|
||||
const [tvDraftGroup, setTvDraftGroup] = useState<HierarchyGroup | null>(null)
|
||||
const [newsDraftGroup, setNewsDraftGroup] = useState<HierarchyGroup | null>(null)
|
||||
const [newsItemEditorDraft, setNewsItemEditorDraft] = useState('')
|
||||
const [newsItemEditorId, setNewsItemEditorId] = useState('')
|
||||
const [newsImportDialogOpen, setNewsImportDialogOpen] = useState(false)
|
||||
const [newsImportTargetGroupId, setNewsImportTargetGroupId] = useState('')
|
||||
const [newsFilters, setNewsFilters] = useState({ status: 'all', sourceType: 'all', region: 'all', tag: 'all' })
|
||||
const [collectionDraftGroup, setCollectionDraftGroup] = useState<HierarchyGroup | null>(null)
|
||||
const [snapshotSelectionBySource, setSnapshotSelectionBySource] = useState<Record<string, string>>({})
|
||||
@@ -2597,6 +2705,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
|
||||
const [smtpTestEmail, setSmtpTestEmail] = useState('')
|
||||
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
|
||||
const [newsImportFile, setNewsImportFile] = useState<File | null>(null)
|
||||
const newsImportInputRef = useRef<HTMLInputElement>(null)
|
||||
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
|
||||
const [resolutionText, setResolutionText] = useState('已处理')
|
||||
const [credentialGuide, setCredentialGuide] = useState<AnyRecord | null>(null)
|
||||
@@ -3140,6 +3250,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
setActiveGroupKey('')
|
||||
setHierarchyDraft('')
|
||||
setTvDraftGroup(null)
|
||||
setNewsItemEditorDraft('')
|
||||
setNewsItemEditorId('')
|
||||
setNewsImportDialogOpen(false)
|
||||
setNewsImportFile(null)
|
||||
setSelected(null)
|
||||
setSelectedHistory([])
|
||||
setMobileResourceDetailOpen(false)
|
||||
@@ -3791,6 +3905,71 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
setBrandUploadFile(file)
|
||||
}
|
||||
|
||||
const createManualNewsGroup = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await axios.post(apiPath('/earth/news-groups'), { name: '新建新闻组' })
|
||||
const group = isObjectRecord(response.data?.group) ? response.data.group : {}
|
||||
const groupId = text(group.id, '')
|
||||
toast({ title: '新闻组已创建', tone: 'success' })
|
||||
await load()
|
||||
if (groupId) {
|
||||
setActiveGroupKey(`news-group:${groupId}`)
|
||||
setHierarchyDraft(formatRaw(group))
|
||||
setMobileHierarchyDetailOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: '创建新闻组失败', description: actionErrorMessage(error), tone: 'error' })
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openManualNewsImportDialog = (groupId: string) => {
|
||||
setNewsImportTargetGroupId(groupId)
|
||||
setNewsImportFile(null)
|
||||
setNewsImportDialogOpen(true)
|
||||
}
|
||||
|
||||
const importManualNewsJson = async () => {
|
||||
if (!newsImportFile) {
|
||||
toast({ title: '请选择 JSON 文件', tone: 'error' })
|
||||
return
|
||||
}
|
||||
if (!newsImportTargetGroupId) {
|
||||
toast({ title: '请选择新闻组', description: 'JSON 导入需要在手动新闻组详情页中执行。', tone: 'error' })
|
||||
return
|
||||
}
|
||||
if (!newsImportFile.name.toLowerCase().endsWith('.json')) {
|
||||
toast({ title: '文件类型不支持', description: '首版只支持 JSON 数组文件。', tone: 'error' })
|
||||
return
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('file', newsImportFile)
|
||||
formData.append('group_id', newsImportTargetGroupId)
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await axios.post(apiPath('/earth/news-items/import'), formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
const result = response.data as AnyRecord
|
||||
setNewsImportFile(null)
|
||||
setNewsImportDialogOpen(false)
|
||||
toast({
|
||||
title: '新闻导入完成',
|
||||
description: `新增 ${text(result.created, '0')} 条,更新 ${text(result.updated, '0')} 条,失败 ${text(result.failed, '0')} 条。`,
|
||||
tone: Number(result.failed || 0) > 0 ? 'error' : 'success',
|
||||
})
|
||||
const activeKey = activeGroupKey
|
||||
await load()
|
||||
if (activeKey) setActiveGroupKey(activeKey)
|
||||
} catch (error) {
|
||||
toast({ title: '导入新闻失败', description: actionErrorMessage(error), tone: 'error' })
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveAdvancedJson = async () => {
|
||||
if (!selected) return
|
||||
let payload: unknown
|
||||
@@ -4893,6 +5072,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const newsPayload = config === configs.earthContent && activeSection.key === 'news_sources'
|
||||
? newsSourcesPayload(activeState.raw)
|
||||
: {}
|
||||
const newsSettingsPayload = config === configs.earthContent
|
||||
? newsSourcesPayload(states.find((state) => state.section.key === 'news_sources')?.raw)
|
||||
: {}
|
||||
if (config === configs.earthContent && activeSection.key === 'news_sources') {
|
||||
const matchesNewsFilter = (group: HierarchyGroup) => {
|
||||
const source = group.record
|
||||
@@ -4913,6 +5095,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
groups = sortGroupsByStatus(arrayAt(newsPayload, 'sources').filter(isObjectRecord).map((source) => newsSourceGroup(source, newsHealthPayload))).filter(matchesNewsFilter)
|
||||
if (newsDraftGroup) groups.push(newsDraftGroup)
|
||||
}
|
||||
if (config === configs.earthContent && activeSection.key === 'news_items') {
|
||||
groups = activeState.rows.map((row) => newsContentGroup(row))
|
||||
}
|
||||
if (config === configs.collection && activeSection.key === 'collection_history') {
|
||||
groups = activeState.rows.map((row) => ({
|
||||
key: row.__rowId,
|
||||
@@ -5027,7 +5212,32 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{ key: 'cooldown_minutes', label: '熔断冷却(分钟)', type: 'number' },
|
||||
{ key: 'circuit_breaker', label: '熔断开关', type: 'boolean' },
|
||||
] : []
|
||||
const fields = newsSourceFields.length ? newsSourceFields : [...scalarFields, ...objectFields]
|
||||
const newsItemReadonly = config === configs.earthContent
|
||||
&& activeSection.key === 'news_items'
|
||||
&& activeGroup
|
||||
&& !activeGroup.record.__isDraft
|
||||
&& activeGroup.record.editable === false
|
||||
const newsItemFields: FieldConfig[] = config === configs.earthContent && activeSection.key === 'news_items' ? [
|
||||
{ key: 'name', label: '组名 / 来源名', disabled: newsItemReadonly },
|
||||
{ key: 'group_type', label: '组类型', disabled: true },
|
||||
{ key: 'source_type', label: '来源类型', disabled: true },
|
||||
{ key: 'count', label: '新闻数量', type: 'number', disabled: true },
|
||||
] : []
|
||||
const manualNewsItemFields: FieldConfig[] = [
|
||||
{ key: 'title', label: '标题' },
|
||||
{ key: 'summary', label: '摘要', type: 'textarea', wide: true },
|
||||
{ key: 'content', label: '正文', type: 'textarea', wide: true },
|
||||
{ key: 'source', label: '内容来源' },
|
||||
{ key: 'url', label: '原文链接', wide: true },
|
||||
{ key: 'region', label: '缺省区域', type: 'select', options: NEWS_REGION_OPTIONS.filter((option) => !['china', 'us'].includes(option.value)) },
|
||||
{ key: 'published_at', label: '发布时间' },
|
||||
{ key: 'category', label: '新闻类型', type: 'select', options: newsCategoryOptions(newsSettingsPayload) },
|
||||
{ key: 'tags_text', label: '标签', wide: true },
|
||||
{ key: 'latitude', label: '纬度', type: 'number' },
|
||||
{ key: 'longitude', label: '经度', type: 'number' },
|
||||
{ key: 'location_label', label: '位置标签' },
|
||||
]
|
||||
const fields = newsSourceFields.length ? newsSourceFields : newsItemFields.length ? newsItemFields : [...scalarFields, ...objectFields]
|
||||
const renderNewsFeedEditor = () => {
|
||||
if (!activeGroup) return null
|
||||
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
|
||||
@@ -5106,6 +5316,146 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
)
|
||||
}
|
||||
|
||||
const startNewsItemEditor = (item?: AnyRecord) => {
|
||||
const groupId = text(activeGroup?.record.id, '')
|
||||
const baseline = item
|
||||
? newsItemToEditor(item)
|
||||
: newsItemToEditor({
|
||||
id: '__new__',
|
||||
title: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
source: '手动添加',
|
||||
source_type: 'manual',
|
||||
region: 'global',
|
||||
category: 'other',
|
||||
published_at: new Date().toISOString(),
|
||||
editable: true,
|
||||
group_id: groupId,
|
||||
})
|
||||
setNewsItemEditorId(text(baseline.id, '__new__'))
|
||||
setNewsItemEditorDraft(formatRaw(baseline))
|
||||
}
|
||||
|
||||
const saveNewsItemEditor = async () => {
|
||||
if (!activeGroup) return
|
||||
const groupId = text(activeGroup.record.id, '')
|
||||
if (!groupId || text(activeGroup.record.group_type, '') !== 'manual') return
|
||||
const current = draftRecord(newsItemEditorDraft, {})
|
||||
const payload = { ...newsItemFromEditor(current), group_id: groupId }
|
||||
const validationError = newsItemValidationError(payload)
|
||||
if (validationError) {
|
||||
toast({ title: '新闻内容不完整', description: validationError, tone: 'error' })
|
||||
return
|
||||
}
|
||||
const itemId = text(current.id, newsItemEditorId)
|
||||
const isNew = !itemId || itemId === '__new__'
|
||||
await requestAction(
|
||||
isNew ? '新增新闻内容' : '保存新闻内容',
|
||||
isNew ? 'post' : 'put',
|
||||
isNew ? '/earth/news-items' : `/earth/news-items/${encodeURIComponent(itemId)}`,
|
||||
payload,
|
||||
)
|
||||
setNewsItemEditorDraft('')
|
||||
setNewsItemEditorId('')
|
||||
}
|
||||
|
||||
const renderNewsContentGroupEditor = () => {
|
||||
if (!activeGroup) return null
|
||||
const group = record
|
||||
const groupId = text(activeGroup.record.id, '')
|
||||
const isManual = text(group.group_type, '') === 'manual'
|
||||
const items = arrayAt(group, 'items')
|
||||
const currentEditorRecord = newsItemEditorDraft ? draftRecord(newsItemEditorDraft, {}) : {}
|
||||
return (
|
||||
<>
|
||||
<section className="an-field-cluster">
|
||||
<div className="an-field-cluster__heading">
|
||||
<h3>{isManual ? '手动新闻组' : 'RSS 来源'}</h3>
|
||||
<p>{isManual ? '组内可按条添加,也可以上传 JSON 数组批量导入。' : 'RSS 来源只读,新闻由抓取与增强链路维护。'}</p>
|
||||
</div>
|
||||
<FieldGrid
|
||||
record={activeGroup.record}
|
||||
draft={hierarchyDraft}
|
||||
onDraftChange={setHierarchyDraft}
|
||||
fields={fields}
|
||||
searchGroupKey={activeGroup.key}
|
||||
/>
|
||||
{isManual ? (
|
||||
<TactileControlGroup className="an-hierarchy-list__footer-actions">
|
||||
<Button size="icon" variant="subtle" icon="plus" title="单条添加" aria-label="单条添加" onClick={() => startNewsItemEditor()} />
|
||||
<Button size="icon" variant="subtle" title="上传 JSON" aria-label="上传 JSON" onClick={() => openManualNewsImportDialog(groupId)}><ImageUp size={15} /></Button>
|
||||
</TactileControlGroup>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="an-field-cluster">
|
||||
<div className="an-field-cluster__heading">
|
||||
<h3>已有新闻</h3>
|
||||
<p>{items.length} 条新闻。单条标题只在这里展示,左侧保持来源/组聚合。</p>
|
||||
</div>
|
||||
<div className="an-news-feed-list">
|
||||
{items.length ? items.map((item, itemIndex) => {
|
||||
const itemId = text(item.id, '')
|
||||
return (
|
||||
<div className="an-news-feed-card" key={itemId || `${text(item.title, 'news-item')}:${itemIndex}`}>
|
||||
<div className="an-news-feed-card__header">
|
||||
<strong>{pick(item, ['title', 'display_title', 'id'], '新闻条目')}</strong>
|
||||
<div className="an-news-feed-card__actions">
|
||||
<StatusText tone={item.verified ? 'success' : 'warning'}>{item.verified ? '已定位' : '待定位'}</StatusText>
|
||||
{isManual ? (
|
||||
<>
|
||||
<Button size="icon" variant="subtle" title="编辑新闻" aria-label="编辑新闻" onClick={() => startNewsItemEditor(item)}><Redo2 size={14} /></Button>
|
||||
<Button size="icon" variant="subtle" title="重新处理" aria-label="重新处理" onClick={() => void requestAction('重新处理新闻', 'post', `/earth/news-items/${encodeURIComponent(itemId)}/reprocess`, undefined, { refresh: true, successDescription: '新闻已重新进入清洗、翻译和定位队列。' })}><RefreshCw size={14} /></Button>
|
||||
<Button size="icon" variant="danger" title="删除新闻" aria-label="删除新闻" onClick={() => setConfirmAction({
|
||||
title: '删除新闻',
|
||||
description: `确认删除 ${pick(item, ['title', 'id'], '新闻条目')}?`,
|
||||
danger: true,
|
||||
confirmLabel: '删除',
|
||||
run: async () => {
|
||||
await requestAction('删除新闻', 'delete', `/earth/news-items/${encodeURIComponent(itemId)}`)
|
||||
setNewsItemEditorDraft('')
|
||||
setNewsItemEditorId('')
|
||||
},
|
||||
})}><Trash2 size={14} /></Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p>{text(item.summary || item.display_summary, '暂无摘要')}</p>
|
||||
<small>{[text(item.source, ''), text(item.region, ''), text(item.category, ''), text(item.published_at, '')].filter(Boolean).join(' · ')}</small>
|
||||
</div>
|
||||
)
|
||||
}) : <EmptyState title="暂无新闻" description={isManual ? '可以单条添加或上传 JSON 数组导入。' : '该 RSS 来源暂无入库新闻。'} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{newsItemEditorDraft ? (
|
||||
<section className="an-field-cluster">
|
||||
<div className="an-field-cluster__heading">
|
||||
<h3>{newsItemEditorId === '__new__' ? '新增新闻' : '编辑新闻'}</h3>
|
||||
<p>保存后会进入清洗、翻译、分类和定位队列。</p>
|
||||
</div>
|
||||
<FieldGrid
|
||||
record={currentEditorRecord}
|
||||
draft={newsItemEditorDraft}
|
||||
onDraftChange={setNewsItemEditorDraft}
|
||||
fields={manualNewsItemFields}
|
||||
searchGroupKey={`${activeGroup.key}:news-editor`}
|
||||
/>
|
||||
<TactileControlGroup className="an-hierarchy-list__footer-actions">
|
||||
<Button variant="subtle" onClick={() => {
|
||||
setNewsItemEditorDraft('')
|
||||
setNewsItemEditorId('')
|
||||
}}><X size={15} />取消</Button>
|
||||
<Button variant="primary" onClick={() => void saveNewsItemEditor()} loading={actionLoading}><Save size={15} />保存新闻</Button>
|
||||
</TactileControlGroup>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const saveCurrent = async () => {
|
||||
if (!activeGroup) return
|
||||
const payload = draftRecord(hierarchyDraft, activeGroup.record)
|
||||
@@ -5184,6 +5534,17 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeSection.key === 'news_items') {
|
||||
if (activeGroup.record.editable === false || text(activeGroup.record.group_type, '') !== 'manual') {
|
||||
toast({ title: 'RSS 来源只读', description: 'RSS 来源组不能在这里重命名或编辑。', tone: 'error' })
|
||||
return
|
||||
}
|
||||
const groupId = text(activeGroup.record.id, '')
|
||||
await requestAction('保存新闻组', 'put', `/earth/news-groups/${encodeURIComponent(groupId)}`, {
|
||||
name: text(payload.name, '').trim(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (activeSection.key === 'tv') {
|
||||
const tv = tvSettingsFromRaw(activeState.raw)
|
||||
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
|
||||
@@ -5368,6 +5729,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
selectHierarchyGroup(group)
|
||||
}} />
|
||||
</TactileControlGroup>
|
||||
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
|
||||
<TactileControlGroup className="an-hierarchy-list__footer-actions">
|
||||
<Button size="icon" variant="subtle" icon="plus" title="新增新闻组" aria-label="新增新闻组" onClick={() => void createManualNewsGroup()} loading={actionLoading} />
|
||||
</TactileControlGroup>
|
||||
) : null
|
||||
|
||||
const hierarchyHeader = config === configs.earthContent && activeSection.key === 'news_sources' ? (
|
||||
@@ -5487,6 +5852,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{config === configs.earthContent && activeSection.key === 'news_items' && activeGroup ? (
|
||||
<>
|
||||
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
|
||||
setHierarchyDraft(formatRaw(activeGroup.record))
|
||||
setNewsItemEditorDraft('')
|
||||
setNewsItemEditorId('')
|
||||
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
|
||||
}}><Redo2 size={15} /></Button>
|
||||
</>
|
||||
) : null}
|
||||
{config === configs.earthContent && activeSection.key === 'tv' && activeGroup ? (
|
||||
<>
|
||||
<Button size="icon" variant="subtle" title={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} aria-label={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} onClick={() => {
|
||||
@@ -5724,6 +6099,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
|
||||
renderNewsContentGroupEditor()
|
||||
) : config === configs.earthContent && activeSection.key === 'tv' ? (
|
||||
<>
|
||||
<div className="an-tv-edit-layout">
|
||||
@@ -6618,6 +6995,41 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</label>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={newsImportDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setNewsImportDialogOpen(open)
|
||||
if (!open) setNewsImportFile(null)
|
||||
}}
|
||||
title="导入 JSON 新闻"
|
||||
description="上传 JSON 数组后,所有条目都会归入当前手动新闻组。"
|
||||
width={520}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="subtle" onClick={() => {
|
||||
setNewsImportDialogOpen(false)
|
||||
setNewsImportFile(null)
|
||||
}} disabled={actionLoading}>取消</Button>
|
||||
<Button variant="primary" onClick={() => void importManualNewsJson()} loading={actionLoading} disabled={!newsImportFile}><ImageUp size={15} />导入</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={newsImportInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(event) => setNewsImportFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<section className="an-field-cluster an-field-cluster--compact">
|
||||
<div className="an-field-cluster__heading">
|
||||
<h3>{newsImportFile ? newsImportFile.name : '选择 JSON 文件'}</h3>
|
||||
<p>首版只支持顶层为数组的 JSON 文件;取消不会清空当前新闻组详情。</p>
|
||||
</div>
|
||||
<Button variant="subtle" onClick={() => newsImportInputRef.current?.click()}><ImageUp size={15} />选择文件</Button>
|
||||
</section>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmAction)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -6784,6 +7196,20 @@ const configs = {
|
||||
}]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
key: 'news_items',
|
||||
label: '新闻内容',
|
||||
url: '/earth/news-groups',
|
||||
map: (payload) => arrayAt(payload, 'groups').map((group) => ({
|
||||
...group,
|
||||
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
|
||||
__module: text(group.group_type, '') === 'manual' ? '手动新闻组' : 'RSS 来源',
|
||||
__status: group.editable === false ? '只读' : '可编辑',
|
||||
__metric: `${text(group.count, '0')} 条`,
|
||||
__endpointKey: 'newsGroups',
|
||||
__endpointLabel: '新闻内容',
|
||||
})),
|
||||
},
|
||||
{ key: 'basemap', label: '底图资源', map: emptyRows },
|
||||
{ key: 'layer_resources', label: '图层资源', map: emptyRows },
|
||||
{ key: 'models_3d', label: '3D 模型', map: emptyRows },
|
||||
|
||||
@@ -70,13 +70,14 @@ class UsbCameraInput:
|
||||
"or start the agent with --dry-run for protocol testing."
|
||||
) from exc
|
||||
|
||||
capture = cv2.VideoCapture(self.spec.index)
|
||||
capture = cv2.VideoCapture(self.spec.index, cv2.CAP_V4L2)
|
||||
if not capture or not capture.isOpened():
|
||||
raise MotionAgentCameraError(f"Unable to open USB camera index {self.spec.index}.")
|
||||
|
||||
capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.spec.width)
|
||||
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.spec.height)
|
||||
capture.set(cv2.CAP_PROP_FPS, self.spec.fps)
|
||||
capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
self._capture = capture
|
||||
|
||||
def read(self) -> Any:
|
||||
@@ -116,6 +117,7 @@ class UrlCameraInput:
|
||||
f"Unable to open camera URL {self.spec.url}. Check the stream URL, "
|
||||
"firewall, and LAN reachability."
|
||||
)
|
||||
capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
self._capture = capture
|
||||
|
||||
def read(self) -> Any:
|
||||
|
||||
@@ -18,7 +18,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--port", type=int, default=None, help="WebSocket bind port.")
|
||||
parser.add_argument("--camera-indexes", default=None, help="Comma-separated USB camera indexes.")
|
||||
parser.add_argument("--camera-urls", default=None, help="Comma-separated RTSP/HTTP camera URLs.")
|
||||
parser.add_argument("--mode", choices=["auto", "single", "dual"], default=None)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["auto", "single", "dual", "dual_redundant", "single_fallback", "calibrated_3d"],
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Start without camera/CV dependencies.")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
SUPPORTED_INPUT_MODES = {"auto", "single", "dual", "dual_redundant", "single_fallback", "calibrated_3d"}
|
||||
|
||||
|
||||
def _parse_camera_indexes(raw: str | None) -> tuple[int, ...]:
|
||||
if not raw:
|
||||
@@ -31,14 +33,17 @@ class MotionAgentConfig:
|
||||
path: str = "/ws/gestures"
|
||||
camera_indexes: tuple[int, ...] = field(default_factory=lambda: (0,))
|
||||
camera_urls: tuple[str, ...] = field(default_factory=tuple)
|
||||
camera_width: int = 1280
|
||||
camera_height: int = 720
|
||||
camera_width: int = 640
|
||||
camera_height: int = 360
|
||||
camera_fps: int = 30
|
||||
mode: str = "auto"
|
||||
confidence_threshold: float = 0.72
|
||||
cooldown_ms: int = 450
|
||||
heartbeat_interval_ms: int = 1000
|
||||
max_event_hz: int = 20
|
||||
max_event_hz: int = 15
|
||||
max_skeleton_hz: int = 8
|
||||
fusion_window_ms: int = 120
|
||||
fusion_conflict_delta: float = 0.18
|
||||
dry_run: bool = False
|
||||
|
||||
@property
|
||||
@@ -53,13 +58,16 @@ class MotionAgentConfig:
|
||||
path=os.getenv("MOTION_AGENT_PATH", "/ws/gestures"),
|
||||
camera_indexes=_parse_camera_indexes(os.getenv("MOTION_AGENT_CAMERA_INDEXES")),
|
||||
camera_urls=_parse_camera_urls(os.getenv("MOTION_AGENT_CAMERA_URLS")),
|
||||
camera_width=int(os.getenv("MOTION_AGENT_CAMERA_WIDTH", "1280")),
|
||||
camera_height=int(os.getenv("MOTION_AGENT_CAMERA_HEIGHT", "720")),
|
||||
camera_width=int(os.getenv("MOTION_AGENT_CAMERA_WIDTH", "640")),
|
||||
camera_height=int(os.getenv("MOTION_AGENT_CAMERA_HEIGHT", "360")),
|
||||
camera_fps=int(os.getenv("MOTION_AGENT_CAMERA_FPS", "30")),
|
||||
mode=os.getenv("MOTION_AGENT_MODE", "auto"),
|
||||
confidence_threshold=float(os.getenv("MOTION_AGENT_CONFIDENCE_THRESHOLD", "0.72")),
|
||||
cooldown_ms=int(os.getenv("MOTION_AGENT_COOLDOWN_MS", "450")),
|
||||
heartbeat_interval_ms=int(os.getenv("MOTION_AGENT_HEARTBEAT_MS", "1000")),
|
||||
max_event_hz=int(os.getenv("MOTION_AGENT_MAX_EVENT_HZ", "20")),
|
||||
max_event_hz=int(os.getenv("MOTION_AGENT_MAX_EVENT_HZ", "15")),
|
||||
max_skeleton_hz=int(os.getenv("MOTION_AGENT_MAX_SKELETON_HZ", "8")),
|
||||
fusion_window_ms=int(os.getenv("MOTION_AGENT_FUSION_WINDOW_MS", "120")),
|
||||
fusion_conflict_delta=float(os.getenv("MOTION_AGENT_FUSION_CONFLICT_DELTA", "0.18")),
|
||||
dry_run=os.getenv("MOTION_AGENT_DRY_RUN", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
|
||||
@@ -7,8 +7,23 @@ import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
GestureName = Literal["rotate_left", "rotate_right", "zoom_in", "zoom_out", "confirm"]
|
||||
PROTOCOL_VERSION = "motion.v2"
|
||||
|
||||
GestureName = Literal[
|
||||
"rotate_left",
|
||||
"rotate_right",
|
||||
"rotate_up",
|
||||
"rotate_down",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"focus_prev",
|
||||
"focus_next",
|
||||
"layer_prev",
|
||||
"layer_next",
|
||||
"confirm",
|
||||
]
|
||||
GesturePhase = Literal["start", "active", "end", "discrete"]
|
||||
InputMode = Literal["single", "dual_redundant", "single_fallback", "calibrated_3d"]
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
@@ -25,6 +40,10 @@ class GestureEvent:
|
||||
seq: int = 0
|
||||
source: str = "motion-agent"
|
||||
mode: str = "single"
|
||||
protocol_version: str = PROTOCOL_VERSION
|
||||
camera_id: str = "unknown"
|
||||
input_mode: str = "single"
|
||||
fusion: dict[str, Any] | None = None
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
type: Literal["gesture"] = "gesture"
|
||||
|
||||
@@ -41,9 +60,17 @@ class StatusEvent:
|
||||
camera_count: int
|
||||
active_camera_ids: tuple[str, ...] = ()
|
||||
mode: str = "single"
|
||||
protocol_version: str = PROTOCOL_VERSION
|
||||
input_mode: str = "single"
|
||||
armed: bool = False
|
||||
paused: bool = False
|
||||
devices_open: bool = False
|
||||
recognizer: str = "mediapipe-opencv"
|
||||
fps: float = 0.0
|
||||
recognition_fps: float = 0.0
|
||||
last_gesture: str | None = None
|
||||
last_fusion_reason: str | None = None
|
||||
enabled_gestures: tuple[str, ...] = ()
|
||||
error: str | None = None
|
||||
timestamp_ms: int = field(default_factory=now_ms)
|
||||
source: str = "motion-agent"
|
||||
@@ -59,6 +86,7 @@ class StatusEvent:
|
||||
@dataclass(frozen=True)
|
||||
class HeartbeatEvent:
|
||||
timestamp_ms: int = field(default_factory=now_ms)
|
||||
protocol_version: str = PROTOCOL_VERSION
|
||||
source: str = "motion-agent"
|
||||
type: Literal["heartbeat"] = "heartbeat"
|
||||
|
||||
@@ -87,6 +115,8 @@ class SkeletonEvent:
|
||||
timestamp_ms: int = field(default_factory=now_ms)
|
||||
source: str = "motion-agent"
|
||||
mode: str = "single"
|
||||
protocol_version: str = PROTOCOL_VERSION
|
||||
input_mode: str = "single"
|
||||
type: Literal["skeleton"] = "skeleton"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -94,3 +124,22 @@ class SkeletonEvent:
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResultEvent:
|
||||
command: str
|
||||
request_id: str | None = None
|
||||
ok: bool = True
|
||||
status: dict[str, Any] = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
timestamp_ms: int = field(default_factory=now_ms)
|
||||
protocol_version: str = PROTOCOL_VERSION
|
||||
source: str = "motion-agent"
|
||||
type: Literal["command_result"] = "command_result"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
@@ -7,12 +7,64 @@ from any specific model implementation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .cameras import MotionAgentDependencyError
|
||||
from .events import GestureName, SkeletonEvent, SkeletonJoint, now_ms
|
||||
|
||||
POSE_MODEL_URL = (
|
||||
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
|
||||
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task"
|
||||
)
|
||||
POSE_MODEL_CACHE_PATH = Path.home() / ".cache" / "planet" / "motion_agent" / "pose_landmarker_lite.task"
|
||||
|
||||
POSE_JOINTS = [
|
||||
(0, "nose"),
|
||||
(7, "left_ear"),
|
||||
(8, "right_ear"),
|
||||
(11, "left_shoulder"),
|
||||
(12, "right_shoulder"),
|
||||
(13, "left_elbow"),
|
||||
(14, "right_elbow"),
|
||||
(15, "left_wrist"),
|
||||
(16, "right_wrist"),
|
||||
]
|
||||
POSE_BONES = [
|
||||
("left_shoulder", "left_elbow"),
|
||||
("left_elbow", "left_wrist"),
|
||||
("right_shoulder", "right_elbow"),
|
||||
("right_elbow", "right_wrist"),
|
||||
("left_shoulder", "right_shoulder"),
|
||||
]
|
||||
|
||||
LEFT_WRIST_LAYER_DELTA_Y = 0.05
|
||||
HEAD_TILT_DELTA_Y = 0.035
|
||||
ARM_PATTERN_TERMINAL_TOLERANCE_DEG = 32
|
||||
ARM_PATTERN_UPPER_TOLERANCE_DEG = 34
|
||||
ARM_PATTERN_MIN_SEGMENT = 0.045
|
||||
ARM_PATTERN_MIN_SIDE_REACH = 0.06
|
||||
ARM_PATTERN_MIN_VERTICAL_REACH = 0.055
|
||||
MIN_GESTURE_INTENSITY = 0.45
|
||||
ARM_PATTERN_INTENSITY_SCALE = 5
|
||||
WRIST_LAYER_INTENSITY_SCALE = 9
|
||||
HEAD_TILT_INTENSITY_SCALE = 12
|
||||
ZOOM_CLOSE_WRIST_SPREAD_FACTOR = 1.28
|
||||
ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR = 1.18
|
||||
ZOOM_TREND_MIN_WRIST_DELTA = 0.010
|
||||
ZOOM_TREND_HEIGHT_TOLERANCE = 0.18
|
||||
ZOOM_TREND_INTENSITY_SCALE = 14
|
||||
ZOOM_TREND_MIN_INTENSITY = 0.6
|
||||
LEFT_ARM_REST_HANGING_BELOW_SHOULDER = 0.13
|
||||
ZOOM_HOLD_HEIGHT_TOLERANCE = 0.18
|
||||
ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT = 0.10
|
||||
ZOOM_HOLD_SPREAD_FACTOR = 1.30
|
||||
ZOOM_HOLD_CLOSE_FACTOR = 0.85
|
||||
ZOOM_HOLD_ELBOW_OUT_FACTOR = 0.25
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GestureObservation:
|
||||
@@ -20,6 +72,7 @@ class GestureObservation:
|
||||
confidence: float
|
||||
intensity: float = 1.0
|
||||
timestamp_ms: int | None = None
|
||||
camera_id: str = "unknown"
|
||||
|
||||
|
||||
class GestureRecognizer(Protocol):
|
||||
@@ -46,16 +99,47 @@ class MediaPipeGestureRecognizer:
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
import cv2 # noqa: F401
|
||||
import mediapipe # noqa: F401
|
||||
import mediapipe as mp
|
||||
from mediapipe.tasks.python.core import base_options as base_options_module
|
||||
from mediapipe.tasks.python.vision import pose_landmarker
|
||||
from mediapipe.tasks.python.vision.core import vision_task_running_mode
|
||||
except ImportError as exc:
|
||||
raise MotionAgentDependencyError(
|
||||
"MediaPipe and OpenCV are required for live gesture recognition. "
|
||||
"Add mediapipe and opencv-python with uv, or use --dry-run for protocol testing."
|
||||
) from exc
|
||||
model_path = _resolve_pose_model_path()
|
||||
options = pose_landmarker.PoseLandmarkerOptions(
|
||||
base_options=base_options_module.BaseOptions(model_asset_path=str(model_path)),
|
||||
running_mode=vision_task_running_mode.VisionTaskRunningMode.VIDEO,
|
||||
num_poses=1,
|
||||
)
|
||||
self._mp = mp
|
||||
self._cv2 = cv2
|
||||
self._landmarker = pose_landmarker.PoseLandmarker.create_from_options(options)
|
||||
self._previous_joints: list[SkeletonJoint] = []
|
||||
self._latest_joints: list[SkeletonJoint] = []
|
||||
self._latest_timestamp_ms = 0
|
||||
self._state: dict[str, str | None] = {"active_pattern_gesture": None}
|
||||
|
||||
def recognize(self, frame: Any) -> GestureObservation | None:
|
||||
_ = frame
|
||||
return None
|
||||
joints = self._detect_joints(frame)
|
||||
self._latest_joints = joints
|
||||
self._latest_timestamp_ms = now_ms()
|
||||
if not joints:
|
||||
self._previous_joints = []
|
||||
self._state["active_pattern_gesture"] = None
|
||||
return None
|
||||
observation = _recognize_gesture(joints, self._previous_joints, self._state)
|
||||
self._previous_joints = joints
|
||||
if observation is None:
|
||||
return None
|
||||
return GestureObservation(
|
||||
gesture=observation["gesture"],
|
||||
confidence=observation["confidence"],
|
||||
intensity=observation["intensity"],
|
||||
timestamp_ms=self._latest_timestamp_ms,
|
||||
)
|
||||
|
||||
def debug_skeleton(
|
||||
self,
|
||||
@@ -66,8 +150,35 @@ class MediaPipeGestureRecognizer:
|
||||
matched_gesture: GestureName | None = None,
|
||||
confidence: float = 0.0,
|
||||
) -> SkeletonEvent | None:
|
||||
_ = frame, camera_id, mode, matched_gesture, confidence
|
||||
return None
|
||||
_ = frame
|
||||
if not self._latest_joints:
|
||||
return None
|
||||
return SkeletonEvent(
|
||||
joints=self._latest_joints,
|
||||
bones=POSE_BONES,
|
||||
matched_gesture=matched_gesture,
|
||||
confidence=confidence,
|
||||
camera_id=camera_id,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
def _detect_joints(self, frame: Any) -> list[SkeletonJoint]:
|
||||
rgb = self._cv2.cvtColor(frame, self._cv2.COLOR_BGR2RGB)
|
||||
image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=rgb)
|
||||
result = self._landmarker.detect_for_video(image, now_ms())
|
||||
if not result.pose_landmarks:
|
||||
return []
|
||||
landmarks = result.pose_landmarks[0]
|
||||
joints: list[SkeletonJoint] = []
|
||||
for index, joint_id in POSE_JOINTS:
|
||||
if index >= len(landmarks):
|
||||
continue
|
||||
point = landmarks[index]
|
||||
x = _clamp01(float(point.x))
|
||||
y = _clamp01(float(point.y))
|
||||
confidence = _clamp01(float(getattr(point, "visibility", getattr(point, "presence", 1.0))))
|
||||
joints.append(SkeletonJoint(joint_id, x, y, confidence))
|
||||
return joints
|
||||
|
||||
|
||||
class NullGestureRecognizer:
|
||||
@@ -116,3 +227,275 @@ class NullGestureRecognizer:
|
||||
camera_id=camera_id,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_pose_model_path() -> Path:
|
||||
configured = os.getenv("MOTION_AGENT_POSE_MODEL_PATH")
|
||||
path = Path(configured).expanduser() if configured else POSE_MODEL_CACHE_PATH
|
||||
if path.exists():
|
||||
return path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
urllib.request.urlretrieve(POSE_MODEL_URL, path)
|
||||
except Exception as exc:
|
||||
raise MotionAgentDependencyError(
|
||||
"MediaPipe pose model is missing and could not be downloaded. "
|
||||
f"Set MOTION_AGENT_POSE_MODEL_PATH to a local .task file or download {POSE_MODEL_URL}."
|
||||
) from exc
|
||||
return path
|
||||
|
||||
|
||||
def _clamp01(value: float) -> float:
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def _get_joint(joints: list[SkeletonJoint], joint_id: str) -> SkeletonJoint | None:
|
||||
return next((joint for joint in joints if joint.id == joint_id), None)
|
||||
|
||||
|
||||
def _vector_between(start: SkeletonJoint | None, end: SkeletonJoint | None) -> dict[str, float] | None:
|
||||
if start is None or end is None:
|
||||
return None
|
||||
dx = end.x - start.x
|
||||
dy = end.y - start.y
|
||||
return {"dx": dx, "dy": dy, "length": (dx * dx + dy * dy) ** 0.5}
|
||||
|
||||
|
||||
def _vector_angle_deg(vector: dict[str, float]) -> float:
|
||||
import math
|
||||
|
||||
return math.atan2(vector["dy"], vector["dx"]) * 180 / math.pi
|
||||
|
||||
|
||||
def _normalize_angle_delta(angle: float, target: float) -> float:
|
||||
delta = angle - target
|
||||
while delta > 180:
|
||||
delta -= 360
|
||||
while delta < -180:
|
||||
delta += 360
|
||||
return abs(delta)
|
||||
|
||||
|
||||
def _is_angle_near(angle: float, target: float, tolerance_deg: float) -> bool:
|
||||
return _normalize_angle_delta(angle, target) <= tolerance_deg
|
||||
|
||||
|
||||
def _is_horizontal_arm(upper_vector: dict[str, float] | None) -> bool:
|
||||
if not upper_vector or upper_vector["length"] < ARM_PATTERN_MIN_SEGMENT:
|
||||
return False
|
||||
angle = _vector_angle_deg(upper_vector)
|
||||
return _is_angle_near(angle, 0, ARM_PATTERN_UPPER_TOLERANCE_DEG) or _is_angle_near(
|
||||
angle, 180, ARM_PATTERN_UPPER_TOLERANCE_DEG
|
||||
)
|
||||
|
||||
|
||||
def _is_terminal_toward(vector: dict[str, float] | None, target_angle: float) -> bool:
|
||||
if not vector or vector["length"] < ARM_PATTERN_MIN_SEGMENT:
|
||||
return False
|
||||
return _is_angle_near(_vector_angle_deg(vector), target_angle, ARM_PATTERN_TERMINAL_TOLERANCE_DEG)
|
||||
|
||||
|
||||
def _gesture(gesture: GestureName, confidence: float, intensity: float) -> dict[str, Any]:
|
||||
return {"gesture": gesture, "confidence": confidence, "intensity": intensity}
|
||||
|
||||
|
||||
def _get_right_arm_pattern(
|
||||
right_shoulder: SkeletonJoint,
|
||||
right_elbow: SkeletonJoint,
|
||||
right_wrist: SkeletonJoint,
|
||||
) -> dict[str, Any] | None:
|
||||
upper = _vector_between(right_shoulder, right_elbow)
|
||||
terminal = _vector_between(right_elbow, right_wrist)
|
||||
if not upper or not terminal:
|
||||
return None
|
||||
intensity = min(1.0, max(MIN_GESTURE_INTENSITY, terminal["length"] * ARM_PATTERN_INTENSITY_SCALE))
|
||||
if _is_terminal_toward(terminal, 180) and right_wrist.x < right_shoulder.x - ARM_PATTERN_MIN_SIDE_REACH:
|
||||
return _gesture("rotate_right", 0.82, intensity)
|
||||
if _is_terminal_toward(terminal, 0) and right_wrist.x > right_shoulder.x + ARM_PATTERN_MIN_SIDE_REACH:
|
||||
return _gesture("rotate_left", 0.82, intensity)
|
||||
if (
|
||||
_is_horizontal_arm(upper)
|
||||
and _is_terminal_toward(terminal, -90)
|
||||
and right_wrist.y < right_elbow.y - ARM_PATTERN_MIN_VERTICAL_REACH
|
||||
):
|
||||
return _gesture("rotate_up", 0.8, intensity)
|
||||
if (
|
||||
_is_horizontal_arm(upper)
|
||||
and _is_terminal_toward(terminal, 90)
|
||||
and right_wrist.y > right_elbow.y + ARM_PATTERN_MIN_VERTICAL_REACH
|
||||
):
|
||||
return _gesture("rotate_down", 0.8, intensity)
|
||||
return None
|
||||
|
||||
|
||||
def _is_zoom_candidate_pose(
|
||||
left_shoulder: SkeletonJoint,
|
||||
left_elbow: SkeletonJoint,
|
||||
left_wrist: SkeletonJoint,
|
||||
right_shoulder: SkeletonJoint,
|
||||
right_elbow: SkeletonJoint,
|
||||
right_wrist: SkeletonJoint,
|
||||
shoulder_width: float,
|
||||
) -> bool:
|
||||
wrists_apart = abs(right_wrist.x - left_wrist.x)
|
||||
both_hands_outside = (
|
||||
left_wrist.x < left_elbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25
|
||||
and left_wrist.x < left_shoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.55
|
||||
and right_wrist.x > right_elbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25
|
||||
and right_wrist.x > right_shoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.55
|
||||
)
|
||||
both_elbows_participating = (
|
||||
left_elbow.x <= left_shoulder.x + ARM_PATTERN_MIN_SIDE_REACH
|
||||
and right_elbow.x >= right_shoulder.x - ARM_PATTERN_MIN_SIDE_REACH
|
||||
)
|
||||
hands_near_center = (
|
||||
left_elbow.x < left_shoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5
|
||||
and right_elbow.x > right_shoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5
|
||||
and left_wrist.x > left_elbow.x
|
||||
and right_wrist.x < right_elbow.x
|
||||
and wrists_apart < shoulder_width * ZOOM_CLOSE_WRIST_SPREAD_FACTOR
|
||||
)
|
||||
return (
|
||||
both_hands_outside
|
||||
and both_elbows_participating
|
||||
and wrists_apart > shoulder_width * ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR
|
||||
) or hands_near_center
|
||||
|
||||
|
||||
def _is_left_arm_at_rest(left_shoulder: SkeletonJoint, left_elbow: SkeletonJoint, left_wrist: SkeletonJoint) -> bool:
|
||||
return (
|
||||
left_wrist.y >= left_shoulder.y + LEFT_ARM_REST_HANGING_BELOW_SHOULDER
|
||||
and left_wrist.x >= left_shoulder.x - ARM_PATTERN_MIN_SIDE_REACH
|
||||
and left_elbow.x >= left_shoulder.x - ARM_PATTERN_MIN_SIDE_REACH
|
||||
)
|
||||
|
||||
|
||||
def _get_zoom_trend(
|
||||
left_wrist: SkeletonJoint,
|
||||
right_wrist: SkeletonJoint,
|
||||
previous_left_wrist: SkeletonJoint | None,
|
||||
previous_right_wrist: SkeletonJoint | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if previous_left_wrist is None or previous_right_wrist is None:
|
||||
return None
|
||||
if abs(right_wrist.y - left_wrist.y) > ZOOM_TREND_HEIGHT_TOLERANCE:
|
||||
return None
|
||||
left_moved = abs(left_wrist.x - previous_left_wrist.x)
|
||||
right_moved = abs(right_wrist.x - previous_right_wrist.x)
|
||||
if left_moved < ZOOM_TREND_MIN_WRIST_DELTA or right_moved < ZOOM_TREND_MIN_WRIST_DELTA:
|
||||
return None
|
||||
spread_delta = abs(right_wrist.x - left_wrist.x) - abs(previous_right_wrist.x - previous_left_wrist.x)
|
||||
min_spread_delta = ZOOM_TREND_MIN_WRIST_DELTA * 2
|
||||
intensity = min(1.0, max(ZOOM_TREND_MIN_INTENSITY, (left_moved + right_moved) * ZOOM_TREND_INTENSITY_SCALE))
|
||||
if spread_delta > min_spread_delta:
|
||||
return _gesture("zoom_in", 0.88, intensity)
|
||||
if spread_delta < -min_spread_delta:
|
||||
return _gesture("zoom_out", 0.86, intensity)
|
||||
return None
|
||||
|
||||
|
||||
def _get_zoom_hold_pose(
|
||||
left_shoulder: SkeletonJoint,
|
||||
left_elbow: SkeletonJoint,
|
||||
left_wrist: SkeletonJoint,
|
||||
right_shoulder: SkeletonJoint,
|
||||
right_elbow: SkeletonJoint,
|
||||
right_wrist: SkeletonJoint,
|
||||
shoulder_width: float,
|
||||
) -> dict[str, Any] | None:
|
||||
if abs(right_wrist.y - left_wrist.y) > ZOOM_HOLD_HEIGHT_TOLERANCE:
|
||||
return None
|
||||
avg_shoulder_y = (left_shoulder.y + right_shoulder.y) / 2
|
||||
wrists_raised = (
|
||||
left_wrist.y <= avg_shoulder_y + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT
|
||||
and right_wrist.y <= avg_shoulder_y + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT
|
||||
)
|
||||
if not wrists_raised:
|
||||
return None
|
||||
span = abs(right_wrist.x - left_wrist.x)
|
||||
if span > shoulder_width * ZOOM_HOLD_SPREAD_FACTOR:
|
||||
return _gesture("zoom_in", 0.82, 0.8)
|
||||
elbows_outward = (
|
||||
abs(left_elbow.x - left_shoulder.x) > shoulder_width * ZOOM_HOLD_ELBOW_OUT_FACTOR
|
||||
and abs(right_elbow.x - right_shoulder.x) > shoulder_width * ZOOM_HOLD_ELBOW_OUT_FACTOR
|
||||
)
|
||||
if elbows_outward and span < shoulder_width * ZOOM_HOLD_CLOSE_FACTOR:
|
||||
return _gesture("zoom_out", 0.80, 0.7)
|
||||
return None
|
||||
|
||||
|
||||
def _apply_pose_latch(observation: dict[str, Any] | None, state: dict[str, str | None]) -> dict[str, Any] | None:
|
||||
if observation is None:
|
||||
return None
|
||||
if state.get("active_pattern_gesture") == observation["gesture"]:
|
||||
return None
|
||||
state["active_pattern_gesture"] = observation["gesture"]
|
||||
return observation
|
||||
|
||||
|
||||
def _recognize_gesture(
|
||||
joints: list[SkeletonJoint],
|
||||
previous_joints: list[SkeletonJoint],
|
||||
state: dict[str, str | None],
|
||||
) -> dict[str, Any] | None:
|
||||
left_ear = _get_joint(joints, "left_ear")
|
||||
right_ear = _get_joint(joints, "right_ear")
|
||||
left_wrist = _get_joint(joints, "left_wrist")
|
||||
right_wrist = _get_joint(joints, "right_wrist")
|
||||
left_elbow = _get_joint(joints, "left_elbow")
|
||||
right_elbow = _get_joint(joints, "right_elbow")
|
||||
left_shoulder = _get_joint(joints, "left_shoulder")
|
||||
right_shoulder = _get_joint(joints, "right_shoulder")
|
||||
previous_left_wrist = _get_joint(previous_joints, "left_wrist")
|
||||
previous_right_wrist = _get_joint(previous_joints, "right_wrist")
|
||||
if not all([left_wrist, right_wrist, left_elbow, right_elbow, left_shoulder, right_shoulder]):
|
||||
return None
|
||||
|
||||
assert left_wrist and right_wrist and left_elbow and right_elbow and left_shoulder and right_shoulder
|
||||
trend = _get_zoom_trend(left_wrist, right_wrist, previous_left_wrist, previous_right_wrist)
|
||||
if trend:
|
||||
state["active_pattern_gesture"] = trend["gesture"]
|
||||
return trend
|
||||
|
||||
shoulder_width = max(0.08, abs(right_shoulder.x - left_shoulder.x))
|
||||
left_raised = left_wrist.y < left_shoulder.y - 0.05
|
||||
right_raised = right_wrist.y < right_shoulder.y - 0.05
|
||||
left_delta_y = left_wrist.y - previous_left_wrist.y if previous_left_wrist else 0.0
|
||||
head_tilt_y = right_ear.y - left_ear.y if left_ear and right_ear else 0.0
|
||||
|
||||
if not right_raised and left_raised and left_delta_y < -LEFT_WRIST_LAYER_DELTA_Y:
|
||||
return _gesture("layer_prev", 0.78, min(1.0, abs(left_delta_y) * WRIST_LAYER_INTENSITY_SCALE))
|
||||
if not right_raised and left_raised and left_delta_y > LEFT_WRIST_LAYER_DELTA_Y:
|
||||
return _gesture("layer_next", 0.78, min(1.0, abs(left_delta_y) * WRIST_LAYER_INTENSITY_SCALE))
|
||||
if head_tilt_y < -HEAD_TILT_DELTA_Y:
|
||||
return _gesture("focus_prev", 0.78, min(1.0, abs(head_tilt_y) * HEAD_TILT_INTENSITY_SCALE))
|
||||
if head_tilt_y > HEAD_TILT_DELTA_Y:
|
||||
return _gesture("focus_next", 0.78, min(1.0, abs(head_tilt_y) * HEAD_TILT_INTENSITY_SCALE))
|
||||
|
||||
zoom_pattern = _get_zoom_hold_pose(
|
||||
left_shoulder,
|
||||
left_elbow,
|
||||
left_wrist,
|
||||
right_shoulder,
|
||||
right_elbow,
|
||||
right_wrist,
|
||||
shoulder_width,
|
||||
)
|
||||
if zoom_pattern:
|
||||
state["active_pattern_gesture"] = zoom_pattern["gesture"]
|
||||
return zoom_pattern
|
||||
|
||||
rotate_allowed = not _is_zoom_candidate_pose(
|
||||
left_shoulder,
|
||||
left_elbow,
|
||||
left_wrist,
|
||||
right_shoulder,
|
||||
right_elbow,
|
||||
right_wrist,
|
||||
shoulder_width,
|
||||
) and _is_left_arm_at_rest(left_shoulder, left_elbow, left_wrist)
|
||||
rotate_pattern = _get_right_arm_pattern(right_shoulder, right_elbow, right_wrist) if rotate_allowed else None
|
||||
if rotate_pattern:
|
||||
return _apply_pose_latch(rotate_pattern, state)
|
||||
state["active_pattern_gesture"] = None
|
||||
return None
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
from dataclasses import replace
|
||||
from typing import Any, get_args
|
||||
|
||||
from .cameras import (
|
||||
MotionAgentCameraError,
|
||||
NullCameraInput,
|
||||
UrlCameraInput,
|
||||
UrlCameraSpec,
|
||||
@@ -16,11 +18,21 @@ from .cameras import (
|
||||
UsbCameraSpec,
|
||||
)
|
||||
from .config import MotionAgentConfig
|
||||
from .events import GestureEvent, HeartbeatEvent, SkeletonEvent, StatusEvent
|
||||
from .recognizer import GestureRecognizer, MediaPipeGestureRecognizer, NullGestureRecognizer
|
||||
from .events import (
|
||||
CommandResultEvent,
|
||||
GestureEvent,
|
||||
GestureName,
|
||||
HeartbeatEvent,
|
||||
SkeletonEvent,
|
||||
StatusEvent,
|
||||
)
|
||||
from .recognizer import GestureObservation, GestureRecognizer, NullGestureRecognizer
|
||||
from .state import GestureStateMachine
|
||||
|
||||
|
||||
ALLOWED_GESTURES = set(get_args(GestureName))
|
||||
|
||||
|
||||
class MotionAgentServer:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -30,7 +42,10 @@ class MotionAgentServer:
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.cameras = list(cameras) if cameras is not None else self._build_cameras(config)
|
||||
self.recognizer = recognizer if recognizer is not None else self._build_recognizer(config)
|
||||
self.recognizer = recognizer or (NullGestureRecognizer() if config.dry_run else None)
|
||||
self.recognizer_name = recognizer.name if recognizer is not None else (
|
||||
"dry-run" if config.dry_run else "mediapipe-opencv"
|
||||
)
|
||||
self.state = GestureStateMachine(
|
||||
confidence_threshold=config.confidence_threshold,
|
||||
cooldown_ms=config.cooldown_ms,
|
||||
@@ -39,7 +54,18 @@ class MotionAgentServer:
|
||||
self.clients: set[Any] = set()
|
||||
self.last_gesture: str | None = None
|
||||
self.last_error: str | None = None
|
||||
self.last_fusion_reason: str | None = None
|
||||
self.fps = 0.0
|
||||
self.recognition_fps = 0.0
|
||||
self.armed = False
|
||||
self.paused = False
|
||||
self.devices_open = False
|
||||
self.debug_skeleton_enabled = False
|
||||
self.enabled_gestures: set[str] = set(ALLOWED_GESTURES)
|
||||
self.fusion_window_ms = config.fusion_window_ms
|
||||
self.fusion_conflict_delta = config.fusion_conflict_delta
|
||||
self._stop = asyncio.Event()
|
||||
self._recognition_subprocess: asyncio.subprocess.Process | None = None
|
||||
|
||||
def _build_cameras(self, config: MotionAgentConfig) -> list[Any]:
|
||||
if config.dry_run:
|
||||
@@ -60,33 +86,40 @@ class MotionAgentServer:
|
||||
]
|
||||
return [UsbCameraInput(spec) for spec in specs]
|
||||
|
||||
def _build_recognizer(self, config: MotionAgentConfig) -> GestureRecognizer:
|
||||
if config.dry_run:
|
||||
return NullGestureRecognizer()
|
||||
return MediaPipeGestureRecognizer()
|
||||
|
||||
def _resolve_mode(self) -> str:
|
||||
if self.config.mode in {"single", "dual", "auto"}:
|
||||
if self.config.mode != "auto":
|
||||
return self.config.mode
|
||||
return "dual" if len(self.cameras) >= 2 else "single"
|
||||
|
||||
def open_cameras(self) -> None:
|
||||
opened = []
|
||||
try:
|
||||
for camera in self.cameras:
|
||||
camera.open()
|
||||
opened.append(camera)
|
||||
except Exception:
|
||||
for camera in opened:
|
||||
with suppress(Exception):
|
||||
camera.close()
|
||||
raise
|
||||
mode = self.config.mode
|
||||
if mode == "dual":
|
||||
return "dual_redundant"
|
||||
if mode in {"single", "dual_redundant", "single_fallback", "calibrated_3d"}:
|
||||
return mode
|
||||
return "dual_redundant" if len(self.cameras) >= 2 else "single"
|
||||
|
||||
def close_cameras(self) -> None:
|
||||
for camera in self.cameras:
|
||||
with suppress(Exception):
|
||||
camera.close()
|
||||
self.devices_open = False
|
||||
|
||||
def rebuild_cameras(
|
||||
self,
|
||||
*,
|
||||
camera_indexes: Iterable[int] | None = None,
|
||||
camera_urls: Iterable[str] | None = None,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
fps: int | None = None,
|
||||
) -> None:
|
||||
self.close_cameras()
|
||||
next_config = replace(
|
||||
self.config,
|
||||
camera_indexes=tuple(camera_indexes) if camera_indexes is not None else self.config.camera_indexes,
|
||||
camera_urls=tuple(camera_urls) if camera_urls is not None else self.config.camera_urls,
|
||||
camera_width=width or self.config.camera_width,
|
||||
camera_height=height or self.config.camera_height,
|
||||
camera_fps=fps or self.config.camera_fps,
|
||||
)
|
||||
self.config = next_config
|
||||
self.cameras = self._build_cameras(next_config)
|
||||
|
||||
def status_event(self, connected: bool = True) -> StatusEvent:
|
||||
return StatusEvent(
|
||||
@@ -94,14 +127,25 @@ class MotionAgentServer:
|
||||
camera_count=len(self.cameras),
|
||||
active_camera_ids=tuple(camera.camera_id for camera in self.cameras),
|
||||
mode=self.state.mode,
|
||||
recognizer=self.recognizer.name,
|
||||
input_mode=self.state.mode,
|
||||
armed=self.armed,
|
||||
paused=self.paused,
|
||||
devices_open=self.devices_open,
|
||||
recognizer=self.recognizer_name,
|
||||
fps=self.fps,
|
||||
recognition_fps=self.recognition_fps,
|
||||
last_gesture=self.last_gesture,
|
||||
last_fusion_reason=self.last_fusion_reason,
|
||||
enabled_gestures=tuple(sorted(self.enabled_gestures)),
|
||||
error=self.last_error,
|
||||
)
|
||||
|
||||
def status_payload(self) -> dict[str, Any]:
|
||||
return self.status_event().to_dict()
|
||||
|
||||
async def broadcast(
|
||||
self,
|
||||
event: GestureEvent | HeartbeatEvent | SkeletonEvent | StatusEvent,
|
||||
event: CommandResultEvent | GestureEvent | HeartbeatEvent | SkeletonEvent | StatusEvent,
|
||||
) -> None:
|
||||
if not self.clients:
|
||||
return
|
||||
@@ -115,6 +159,153 @@ class MotionAgentServer:
|
||||
for websocket in stale:
|
||||
self.clients.discard(websocket)
|
||||
|
||||
async def broadcast_payload(self, payload: dict[str, Any]) -> None:
|
||||
if not self.clients:
|
||||
return
|
||||
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
stale = []
|
||||
for websocket in self.clients:
|
||||
try:
|
||||
await websocket.send(message)
|
||||
except Exception:
|
||||
stale.append(websocket)
|
||||
for websocket in stale:
|
||||
self.clients.discard(websocket)
|
||||
|
||||
def _parse_command(self, raw_message: Any) -> tuple[str | None, str | None, dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(raw_message) if isinstance(raw_message, str) else raw_message
|
||||
except json.JSONDecodeError:
|
||||
return None, None, {}
|
||||
if not isinstance(data, dict) or data.get("type") != "command":
|
||||
return None, None, {}
|
||||
command = str(data.get("command") or "").strip()
|
||||
request_id = data.get("request_id")
|
||||
payload = data.get("payload")
|
||||
return command, str(request_id) if request_id is not None else None, payload if isinstance(payload, dict) else {}
|
||||
|
||||
async def handle_command(self, raw_message: Any) -> CommandResultEvent:
|
||||
command, request_id, payload = self._parse_command(raw_message)
|
||||
if not command:
|
||||
return CommandResultEvent(
|
||||
command="unknown",
|
||||
request_id=request_id,
|
||||
ok=False,
|
||||
status=self.status_payload(),
|
||||
error="Expected a JSON command message.",
|
||||
)
|
||||
|
||||
try:
|
||||
if command == "open_devices":
|
||||
self._apply_device_payload(payload)
|
||||
await self.restart_recognition_subprocess()
|
||||
elif command == "close_devices":
|
||||
await self.stop_recognition_subprocess()
|
||||
self.devices_open = False
|
||||
await self.broadcast(self.status_event(connected=True))
|
||||
elif command == "rescan_devices":
|
||||
self._apply_device_payload(payload, rebuild_only=True)
|
||||
await self.restart_recognition_subprocess()
|
||||
elif command == "set_armed":
|
||||
self.armed = bool(payload.get("armed", True))
|
||||
elif command == "set_paused":
|
||||
self.paused = bool(payload.get("paused", True))
|
||||
elif command == "set_input_mode":
|
||||
self._set_input_mode(str(payload.get("input_mode") or payload.get("mode") or "auto"))
|
||||
await self.restart_recognition_subprocess()
|
||||
elif command == "set_camera_config":
|
||||
self._apply_device_payload(payload, rebuild_only=not self.devices_open)
|
||||
await self.restart_recognition_subprocess()
|
||||
elif command == "set_fusion_config":
|
||||
self.fusion_window_ms = int(payload.get("fusion_window_ms", self.fusion_window_ms))
|
||||
self.fusion_conflict_delta = float(
|
||||
payload.get("fusion_conflict_delta", self.fusion_conflict_delta)
|
||||
)
|
||||
elif command == "set_debug_options":
|
||||
if "skeleton" in payload:
|
||||
next_enabled = bool(payload["skeleton"])
|
||||
if self.debug_skeleton_enabled != next_enabled:
|
||||
self.debug_skeleton_enabled = next_enabled
|
||||
await self.restart_recognition_subprocess()
|
||||
elif command == "set_enabled_gestures":
|
||||
gestures = payload.get("gestures")
|
||||
if not isinstance(gestures, list):
|
||||
raise ValueError("set_enabled_gestures requires payload.gestures list.")
|
||||
next_gestures = {str(gesture) for gesture in gestures if str(gesture) in ALLOWED_GESTURES}
|
||||
self.enabled_gestures = next_gestures or set(ALLOWED_GESTURES)
|
||||
elif command in {"get_status", "ping"}:
|
||||
pass
|
||||
else:
|
||||
return CommandResultEvent(
|
||||
command=command,
|
||||
request_id=request_id,
|
||||
ok=False,
|
||||
status=self.status_payload(),
|
||||
error=f"Unsupported command: {command}",
|
||||
)
|
||||
return CommandResultEvent(
|
||||
command=command,
|
||||
request_id=request_id,
|
||||
ok=True,
|
||||
status=self.status_payload(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.last_error = str(exc)
|
||||
return CommandResultEvent(
|
||||
command=command,
|
||||
request_id=request_id,
|
||||
ok=False,
|
||||
status=self.status_payload(),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
def _apply_device_payload(self, payload: dict[str, Any], *, rebuild_only: bool = False) -> None:
|
||||
if "input_mode" in payload or "mode" in payload:
|
||||
self._set_input_mode(str(payload.get("input_mode") or payload.get("mode")))
|
||||
|
||||
indexes = payload.get("camera_indexes")
|
||||
urls = payload.get("camera_urls")
|
||||
width = payload.get("width")
|
||||
height = payload.get("height")
|
||||
fps = payload.get("fps")
|
||||
should_rebuild = any(value is not None for value in (indexes, urls, width, height, fps))
|
||||
if should_rebuild:
|
||||
self.rebuild_cameras(
|
||||
camera_indexes=self._coerce_indexes(indexes) if indexes is not None else None,
|
||||
camera_urls=self._coerce_urls(urls) if urls is not None else None,
|
||||
width=int(width) if width else None,
|
||||
height=int(height) if height else None,
|
||||
fps=int(fps) if fps else None,
|
||||
)
|
||||
elif rebuild_only:
|
||||
self.rebuild_cameras()
|
||||
|
||||
def _set_input_mode(self, mode: str) -> None:
|
||||
normalized = "dual_redundant" if mode == "dual" else mode
|
||||
if normalized == "auto":
|
||||
normalized = "dual_redundant" if len(self.cameras) >= 2 else "single"
|
||||
if normalized not in {"single", "dual_redundant", "single_fallback", "calibrated_3d"}:
|
||||
raise ValueError(f"Unsupported input mode: {mode}")
|
||||
self.state.mode = normalized
|
||||
|
||||
def _coerce_indexes(self, value: Any) -> tuple[int, ...]:
|
||||
if value is None:
|
||||
return self.config.camera_indexes
|
||||
if isinstance(value, str):
|
||||
return tuple(int(item.strip()) for item in value.split(",") if item.strip())
|
||||
if isinstance(value, Iterable):
|
||||
return tuple(int(item) for item in value)
|
||||
raise ValueError("camera_indexes must be a list or comma-separated string.")
|
||||
|
||||
def _coerce_urls(self, value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return self.config.camera_urls
|
||||
if isinstance(value, str):
|
||||
return tuple(item.strip() for item in value.split(",") if item.strip())
|
||||
if isinstance(value, Iterable):
|
||||
return tuple(str(item).strip() for item in value if str(item).strip())
|
||||
raise ValueError("camera_urls must be a list or comma-separated string.")
|
||||
|
||||
async def handler(self, websocket: Any, path: str | None = None) -> None:
|
||||
if path is not None and path != self.config.path:
|
||||
await websocket.close(code=1008, reason="Unsupported motion agent path")
|
||||
@@ -122,8 +313,12 @@ class MotionAgentServer:
|
||||
self.clients.add(websocket)
|
||||
await websocket.send(self.status_event().to_json())
|
||||
try:
|
||||
async for _message in websocket:
|
||||
await websocket.send(self.status_event().to_json())
|
||||
async for message in websocket:
|
||||
command, _request_id, _payload = self._parse_command(message)
|
||||
if command:
|
||||
await websocket.send((await self.handle_command(message)).to_json())
|
||||
else:
|
||||
await websocket.send(self.status_event().to_json())
|
||||
finally:
|
||||
self.clients.discard(websocket)
|
||||
|
||||
@@ -133,38 +328,173 @@ class MotionAgentServer:
|
||||
await self.broadcast(HeartbeatEvent())
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def recognition_loop(self) -> None:
|
||||
min_interval = 1 / max(1, self.config.max_event_hz)
|
||||
async def start_recognition_subprocess(self) -> None:
|
||||
if self._recognition_subprocess and self._recognition_subprocess.returncode is None:
|
||||
return
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"motion_agent.worker",
|
||||
"--camera-indexes",
|
||||
",".join(str(index) for index in self.config.camera_indexes),
|
||||
"--mode",
|
||||
self.config.mode,
|
||||
]
|
||||
if self.config.camera_urls:
|
||||
command.extend(["--camera-urls", ",".join(self.config.camera_urls)])
|
||||
if self.config.dry_run:
|
||||
command.append("--dry-run")
|
||||
self._recognition_subprocess = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
"--width",
|
||||
str(self.config.camera_width),
|
||||
"--height",
|
||||
str(self.config.camera_height),
|
||||
"--fps",
|
||||
str(self.config.camera_fps),
|
||||
"--max-event-hz",
|
||||
str(self.config.max_event_hz),
|
||||
"--max-skeleton-hz",
|
||||
str(self.config.max_skeleton_hz if self.debug_skeleton_enabled else 0),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
async def restart_recognition_subprocess(self) -> None:
|
||||
await self.stop_recognition_subprocess()
|
||||
await self.start_recognition_subprocess()
|
||||
|
||||
async def stop_recognition_subprocess(self) -> None:
|
||||
process = self._recognition_subprocess
|
||||
self._recognition_subprocess = None
|
||||
if process is None or process.returncode is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def recognition_subprocess_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
process = self._recognition_subprocess
|
||||
if process is None or process.stdout is None:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
line = await process.stdout.readline()
|
||||
if not line:
|
||||
if self._recognition_subprocess is process and process.returncode is not None:
|
||||
self.devices_open = False
|
||||
if self.last_error is None and process.returncode not in {0, None}:
|
||||
self.last_error = f"Motion recognition worker exited with code {process.returncode}."
|
||||
await self.broadcast(self.status_event(connected=True))
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
try:
|
||||
for camera in self.cameras:
|
||||
frame = camera.read()
|
||||
observation = self.recognizer.recognize(frame)
|
||||
matched_gesture = None
|
||||
matched_confidence = 0.0
|
||||
if observation is None:
|
||||
event = None
|
||||
else:
|
||||
event = self.state.accept(observation)
|
||||
if event is not None:
|
||||
self.last_gesture = event.gesture
|
||||
matched_gesture = event.gesture
|
||||
matched_confidence = event.confidence
|
||||
await self.broadcast(event)
|
||||
skeleton = self.recognizer.debug_skeleton(
|
||||
frame,
|
||||
camera_id=camera.camera_id,
|
||||
mode=self.state.mode,
|
||||
matched_gesture=matched_gesture,
|
||||
confidence=matched_confidence,
|
||||
)
|
||||
if skeleton is not None:
|
||||
await self.broadcast(skeleton)
|
||||
except MotionAgentCameraError as exc:
|
||||
self.last_error = str(exc)
|
||||
message = json.loads(line.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
kind = message.get("kind")
|
||||
payload = message.get("payload")
|
||||
if kind == "status" and isinstance(payload, dict):
|
||||
self.devices_open = bool(payload.get("devices_open", self.devices_open))
|
||||
if payload.get("recognizer"):
|
||||
self.recognizer_name = str(payload["recognizer"])
|
||||
self.fps = float(payload.get("fps", self.fps) or 0)
|
||||
self.recognition_fps = float(payload.get("recognition_fps", self.recognition_fps) or 0)
|
||||
self.last_error = payload.get("error")
|
||||
await self.broadcast(self.status_event(connected=True))
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(min_interval)
|
||||
elif kind == "skeleton" and isinstance(payload, dict):
|
||||
if self.debug_skeleton_enabled:
|
||||
await self.broadcast_payload(payload)
|
||||
elif kind == "observations" and isinstance(payload, list):
|
||||
observations = [
|
||||
GestureObservation(
|
||||
gesture=item.get("gesture"),
|
||||
confidence=float(item.get("confidence", 0)),
|
||||
intensity=float(item.get("intensity", 1)),
|
||||
timestamp_ms=item.get("timestamp_ms"),
|
||||
camera_id=str(item.get("camera_id", "unknown")),
|
||||
)
|
||||
for item in payload
|
||||
if isinstance(item, dict) and item.get("gesture")
|
||||
]
|
||||
if observations and self.armed and not self.paused:
|
||||
event = self._accept_observations(observations)
|
||||
if event is not None:
|
||||
self.last_gesture = event.gesture
|
||||
await self.broadcast(event)
|
||||
|
||||
def _accept_observations(self, observations: list[GestureObservation]) -> GestureEvent | None:
|
||||
if not observations:
|
||||
return None
|
||||
observations = [item for item in observations if item.gesture in self.enabled_gestures]
|
||||
if not observations:
|
||||
return None
|
||||
selected = observations[0]
|
||||
fusion: dict[str, Any] | None = None
|
||||
if self.state.mode in {"dual_redundant", "calibrated_3d"} and len(observations) > 1:
|
||||
selected, fusion = self._fuse_observations(observations)
|
||||
event = self.state.accept(selected)
|
||||
if event is not None and fusion is not None:
|
||||
event = replace(
|
||||
event,
|
||||
camera_id="fusion",
|
||||
fusion=fusion,
|
||||
payload={**event.payload, "fusion": fusion},
|
||||
)
|
||||
return event
|
||||
|
||||
def _fuse_observations(
|
||||
self,
|
||||
observations: list[GestureObservation],
|
||||
) -> tuple[GestureObservation, dict[str, Any] | None]:
|
||||
ordered = sorted(observations, key=lambda item: item.confidence, reverse=True)
|
||||
best = ordered[0]
|
||||
same = [item for item in ordered if item.gesture == best.gesture]
|
||||
if len(same) >= 2:
|
||||
confidence = min(1.0, sum(item.confidence for item in same) / len(same) + 0.06)
|
||||
intensity = sum(item.intensity for item in same) / len(same)
|
||||
self.last_fusion_reason = "matched_observations"
|
||||
return (
|
||||
GestureObservation(
|
||||
gesture=best.gesture,
|
||||
confidence=confidence,
|
||||
intensity=intensity,
|
||||
timestamp_ms=best.timestamp_ms,
|
||||
camera_id="fusion",
|
||||
),
|
||||
{
|
||||
"source_cameras": [item.camera_id for item in same],
|
||||
"window_ms": self.fusion_window_ms,
|
||||
"reason": self.last_fusion_reason,
|
||||
},
|
||||
)
|
||||
if len(ordered) > 1 and best.confidence - ordered[1].confidence < self.fusion_conflict_delta:
|
||||
self.last_fusion_reason = "conflict_ignored"
|
||||
return (
|
||||
GestureObservation(
|
||||
gesture=best.gesture,
|
||||
confidence=0,
|
||||
intensity=best.intensity,
|
||||
timestamp_ms=best.timestamp_ms,
|
||||
camera_id=best.camera_id,
|
||||
),
|
||||
{
|
||||
"source_cameras": [item.camera_id for item in ordered],
|
||||
"window_ms": self.fusion_window_ms,
|
||||
"reason": self.last_fusion_reason,
|
||||
},
|
||||
)
|
||||
self.last_fusion_reason = "highest_confidence"
|
||||
return (
|
||||
best,
|
||||
{
|
||||
"source_cameras": [item.camera_id for item in ordered],
|
||||
"window_ms": self.fusion_window_ms,
|
||||
"reason": self.last_fusion_reason,
|
||||
},
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
try:
|
||||
@@ -172,21 +502,21 @@ class MotionAgentServer:
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("websockets is required to run the motion agent server.") from exc
|
||||
|
||||
self.open_cameras()
|
||||
async with websockets.serve(self.handler, self.config.host, self.config.port):
|
||||
print(f"Motion agent listening on {self.config.websocket_url}", flush=True)
|
||||
await self.start_recognition_subprocess()
|
||||
heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||||
recognition_task = asyncio.create_task(self.recognition_loop())
|
||||
recognition_task = asyncio.create_task(self.recognition_subprocess_loop())
|
||||
try:
|
||||
await self._stop.wait()
|
||||
finally:
|
||||
await self.stop_recognition_subprocess()
|
||||
heartbeat_task.cancel()
|
||||
recognition_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await heartbeat_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await recognition_task
|
||||
self.close_cameras()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
@@ -41,4 +41,6 @@ class GestureStateMachine:
|
||||
timestamp_ms=timestamp_ms,
|
||||
seq=self._seq,
|
||||
mode=self.mode,
|
||||
input_mode=self.mode,
|
||||
camera_id=observation.camera_id,
|
||||
)
|
||||
|
||||
293
motion_agent/worker.py
Normal file
293
motion_agent/worker.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Recognition worker process for Motion Agent.
|
||||
|
||||
The WebSocket server stays in the parent process. This worker owns OpenCV and
|
||||
MediaPipe so native camera/model work cannot block or crash the control plane.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from .cameras import NullCameraInput, UrlCameraInput, UrlCameraSpec, UsbCameraInput, UsbCameraSpec
|
||||
from .config import MotionAgentConfig
|
||||
from .recognizer import MediaPipeGestureRecognizer, NullGestureRecognizer
|
||||
|
||||
|
||||
class OutputClosed(Exception):
|
||||
"""Signal that the parent process stopped consuming worker events."""
|
||||
|
||||
|
||||
def _parse_indexes(raw: str | None, fallback: tuple[int, ...]) -> tuple[int, ...]:
|
||||
if raw is None:
|
||||
return fallback
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
return tuple(int(item) for item in values) or fallback
|
||||
|
||||
|
||||
def _parse_urls(raw: str | None, fallback: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if raw is None:
|
||||
return fallback
|
||||
return tuple(item.strip() for item in raw.split(",") if item.strip())
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run the Planet motion recognition worker.")
|
||||
parser.add_argument("--camera-indexes", default=None)
|
||||
parser.add_argument("--camera-urls", default=None)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["auto", "single", "dual", "dual_redundant", "single_fallback", "calibrated_3d"],
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("--width", type=int, default=None)
|
||||
parser.add_argument("--height", type=int, default=None)
|
||||
parser.add_argument("--fps", type=int, default=None)
|
||||
parser.add_argument("--max-event-hz", type=int, default=None)
|
||||
parser.add_argument("--max-skeleton-hz", type=int, default=None)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def _config_from_args(argv: list[str] | None = None) -> MotionAgentConfig:
|
||||
args = _build_parser().parse_args(argv)
|
||||
config = MotionAgentConfig.from_env()
|
||||
return replace(
|
||||
config,
|
||||
camera_indexes=_parse_indexes(args.camera_indexes, config.camera_indexes),
|
||||
camera_urls=_parse_urls(args.camera_urls, config.camera_urls),
|
||||
camera_width=args.width or config.camera_width,
|
||||
camera_height=args.height or config.camera_height,
|
||||
camera_fps=args.fps or config.camera_fps,
|
||||
max_event_hz=args.max_event_hz or config.max_event_hz,
|
||||
max_skeleton_hz=args.max_skeleton_hz if args.max_skeleton_hz is not None else config.max_skeleton_hz,
|
||||
mode=args.mode or config.mode,
|
||||
dry_run=args.dry_run or config.dry_run,
|
||||
)
|
||||
|
||||
|
||||
def _build_cameras(config: MotionAgentConfig) -> list[Any]:
|
||||
if config.dry_run:
|
||||
return [NullCameraInput()]
|
||||
if config.camera_urls:
|
||||
return [
|
||||
UrlCameraInput(UrlCameraSpec(url=url, camera_id=f"url:{index}"))
|
||||
for index, url in enumerate(config.camera_urls)
|
||||
]
|
||||
return [
|
||||
UsbCameraInput(
|
||||
UsbCameraSpec(
|
||||
index=index,
|
||||
width=config.camera_width,
|
||||
height=config.camera_height,
|
||||
fps=config.camera_fps,
|
||||
)
|
||||
)
|
||||
for index in config.camera_indexes
|
||||
]
|
||||
|
||||
|
||||
def _resolve_mode(config: MotionAgentConfig, camera_count: int) -> str:
|
||||
if config.mode == "dual":
|
||||
return "dual_redundant"
|
||||
if config.mode in {"single", "dual_redundant", "single_fallback", "calibrated_3d"}:
|
||||
return config.mode
|
||||
return "dual_redundant" if camera_count >= 2 else "single"
|
||||
|
||||
|
||||
def _emit(kind: str, payload: Any) -> None:
|
||||
try:
|
||||
print(
|
||||
json.dumps({"kind": kind, "payload": payload}, ensure_ascii=False, separators=(",", ":")),
|
||||
flush=True,
|
||||
)
|
||||
except BrokenPipeError as exc:
|
||||
# Prevent Python's interpreter shutdown flush from printing another
|
||||
# BrokenPipeError after the parent process closes the JSONL pipe.
|
||||
sys.stdout = None
|
||||
raise OutputClosed from exc
|
||||
|
||||
|
||||
class LatestFrameReader:
|
||||
"""Continuously read one camera and expose only the newest frame.
|
||||
|
||||
OpenCV and RTSP sources can buffer frames when recognition is slower than
|
||||
capture. The recognizer should never drain that backlog; it should always
|
||||
work on the latest frame so gesture latency stays bounded.
|
||||
"""
|
||||
|
||||
def __init__(self, camera: Any, *, fallback_fps: int) -> None:
|
||||
self.camera = camera
|
||||
self.camera_id = str(getattr(camera, "camera_id", "unknown"))
|
||||
self._fallback_interval = 1 / max(1, fallback_fps)
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._frame: Any = None
|
||||
self._frame_seq = 0
|
||||
self._frame_at = 0.0
|
||||
self._error: str | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name=f"motion-frame-reader-{self.camera_id}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1)
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
with self._lock:
|
||||
return self._frame_seq
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
with self._lock:
|
||||
return self._error
|
||||
|
||||
def latest_after(self, last_seq: int) -> tuple[bool, int, Any, float]:
|
||||
with self._lock:
|
||||
if self._frame_seq <= last_seq:
|
||||
return False, last_seq, None, self._frame_at
|
||||
return True, self._frame_seq, self._frame, self._frame_at
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
frame = self.camera.read()
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._error = str(exc)
|
||||
return
|
||||
with self._lock:
|
||||
self._frame = frame
|
||||
self._frame_seq += 1
|
||||
self._frame_at = time.monotonic()
|
||||
self._error = None
|
||||
if frame is None:
|
||||
time.sleep(self._fallback_interval)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
config = _config_from_args(argv)
|
||||
cameras = _build_cameras(config)
|
||||
recognizer = NullGestureRecognizer() if config.dry_run else MediaPipeGestureRecognizer()
|
||||
mode = _resolve_mode(config, len(cameras))
|
||||
min_interval = 1 / max(1, config.max_event_hz)
|
||||
skeleton_interval = 1 / config.max_skeleton_hz if config.max_skeleton_hz > 0 else None
|
||||
last_skeleton_at = 0.0
|
||||
opened: list[Any] = []
|
||||
readers: list[LatestFrameReader] = []
|
||||
last_frame_seq: dict[str, int] = {}
|
||||
last_stats_at = time.monotonic()
|
||||
last_frame_total = 0
|
||||
recognition_total = 0
|
||||
try:
|
||||
for camera in cameras:
|
||||
camera.open()
|
||||
opened.append(camera)
|
||||
reader = LatestFrameReader(camera, fallback_fps=config.camera_fps)
|
||||
reader.start()
|
||||
readers.append(reader)
|
||||
last_frame_seq[reader.camera_id] = 0
|
||||
_emit(
|
||||
"status",
|
||||
{
|
||||
"devices_open": True,
|
||||
"recognizer": recognizer.name,
|
||||
"error": None,
|
||||
"fps": 0.0,
|
||||
"recognition_fps": 0.0,
|
||||
},
|
||||
)
|
||||
while True:
|
||||
loop_started_at = time.monotonic()
|
||||
observations = []
|
||||
for reader in readers:
|
||||
error = reader.error
|
||||
if error:
|
||||
raise RuntimeError(error)
|
||||
has_frame, seq, frame, _frame_at = reader.latest_after(last_frame_seq.get(reader.camera_id, 0))
|
||||
if not has_frame:
|
||||
continue
|
||||
last_frame_seq[reader.camera_id] = seq
|
||||
observation = recognizer.recognize(frame)
|
||||
recognition_total += 1
|
||||
matched_gesture = None
|
||||
matched_confidence = 0.0
|
||||
if observation is not None:
|
||||
matched_gesture = observation.gesture
|
||||
matched_confidence = observation.confidence
|
||||
observations.append(
|
||||
{
|
||||
"gesture": observation.gesture,
|
||||
"confidence": observation.confidence,
|
||||
"intensity": observation.intensity,
|
||||
"timestamp_ms": observation.timestamp_ms,
|
||||
"camera_id": reader.camera_id,
|
||||
}
|
||||
)
|
||||
now = time.monotonic()
|
||||
if skeleton_interval is not None and now - last_skeleton_at >= skeleton_interval:
|
||||
skeleton = recognizer.debug_skeleton(
|
||||
frame,
|
||||
camera_id=reader.camera_id,
|
||||
mode=mode,
|
||||
matched_gesture=matched_gesture,
|
||||
confidence=matched_confidence,
|
||||
)
|
||||
if skeleton is not None:
|
||||
_emit("skeleton", skeleton.to_dict())
|
||||
last_skeleton_at = now
|
||||
if observations:
|
||||
_emit("observations", observations)
|
||||
now = time.monotonic()
|
||||
if now - last_stats_at >= 1:
|
||||
frame_total = sum(reader.frame_count for reader in readers)
|
||||
elapsed = max(0.001, now - last_stats_at)
|
||||
_emit(
|
||||
"status",
|
||||
{
|
||||
"devices_open": True,
|
||||
"recognizer": recognizer.name,
|
||||
"error": None,
|
||||
"fps": round((frame_total - last_frame_total) / elapsed, 2),
|
||||
"recognition_fps": round(recognition_total / elapsed, 2),
|
||||
},
|
||||
)
|
||||
last_stats_at = now
|
||||
last_frame_total = frame_total
|
||||
recognition_total = 0
|
||||
time.sleep(max(0.001, min_interval - (time.monotonic() - loop_started_at)))
|
||||
except (KeyboardInterrupt, OutputClosed):
|
||||
return 0
|
||||
except Exception as exc:
|
||||
_emit("status", {"devices_open": False, "recognizer": recognizer.name, "error": str(exc)})
|
||||
return 2
|
||||
finally:
|
||||
for reader in readers:
|
||||
reader.stop()
|
||||
for camera in opened:
|
||||
try:
|
||||
camera.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_emit("status", {"devices_open": False, "recognizer": recognizer.name, "error": None})
|
||||
except OutputClosed:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
718
planet.sh
718
planet.sh
@@ -116,6 +116,10 @@ FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
||||
PLANET_STATE_DIR="${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}"
|
||||
PLANET_CACHE_DIR="${PLANET_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/planet}"
|
||||
PLANET_UV_TUNA_INDEX_URL="${PLANET_UV_TUNA_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/}"
|
||||
USBIPD_WIN_FALLBACK_VERSION="${USBIPD_WIN_FALLBACK_VERSION:-5.3.0}"
|
||||
USBIPD_WIN_FALLBACK_URL_X64="${USBIPD_WIN_FALLBACK_URL_X64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_x64.msi}"
|
||||
USBIPD_WIN_FALLBACK_URL_ARM64="${USBIPD_WIN_FALLBACK_URL_ARM64:-https://github.com/dorssel/usbipd-win/releases/download/v${USBIPD_WIN_FALLBACK_VERSION}/usbipd-win_${USBIPD_WIN_FALLBACK_VERSION}_arm64.msi}"
|
||||
USBIPD_WIN_DOWNLOAD_DIR="${USBIPD_WIN_DOWNLOAD_DIR:-$SCRIPT_DIR/downloads/usbipd-win}"
|
||||
BACKEND_PID_FILE="$PLANET_STATE_DIR/backend.pid"
|
||||
BACKEND_LOG_FILE="$PLANET_STATE_DIR/backend.log"
|
||||
FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid"
|
||||
@@ -132,6 +136,7 @@ PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env"
|
||||
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
|
||||
MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid"
|
||||
MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log"
|
||||
MOTION_AGENT_SKIPPED_FILE="$PLANET_STATE_DIR/motion_agent.skipped"
|
||||
AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256"
|
||||
AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log"
|
||||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}"
|
||||
@@ -144,6 +149,7 @@ AI_PROVIDER_RECREATE_REQUIRED=0
|
||||
START_RUN_ACTIVE=0
|
||||
START_RUN_COMPLETED=0
|
||||
STARTED_BACKEND_THIS_RUN=0
|
||||
MOTION_AGENT_SKIPPED_THIS_RUN=0
|
||||
STARTED_FRONTEND_THIS_RUN=0
|
||||
STARTED_MOTION_AGENT_THIS_RUN=0
|
||||
AI_PROVIDER_RUNTIME_ENV_NAMES=(
|
||||
@@ -1666,6 +1672,13 @@ detect_motion_agent_camera_indexes() {
|
||||
local devices=""
|
||||
local device=""
|
||||
local index=""
|
||||
local cv2_devices=""
|
||||
|
||||
cv2_devices="$(detect_motion_agent_camera_indexes_with_cv2 || true)"
|
||||
if [ -n "$cv2_devices" ]; then
|
||||
printf "%s" "$cv2_devices"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||||
devices="$(v4l2-ctl --list-devices 2>/dev/null | sed -nE 's/^[[:space:]]*\\/dev\\/video([0-9]+).*/\\1/p' || true)"
|
||||
@@ -1684,10 +1697,348 @@ detect_motion_agent_camera_indexes() {
|
||||
printf "%s\n" "$devices" | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
|
||||
}
|
||||
|
||||
detect_motion_agent_camera_indexes_with_cv2() {
|
||||
local python_bin="$SCRIPT_DIR/.venv/bin/python"
|
||||
|
||||
[ -x "$python_bin" ] || return 1
|
||||
|
||||
"$python_bin" - <<'PY' 2>/dev/null
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import re
|
||||
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
indexes: list[int] = []
|
||||
for path in glob.glob("/dev/video*"):
|
||||
match = re.search(r"/dev/video(\d+)$", path)
|
||||
if match:
|
||||
indexes.append(int(match.group(1)))
|
||||
|
||||
usable: list[str] = []
|
||||
for index in sorted(set(indexes)):
|
||||
capture = cv2.VideoCapture(index, cv2.CAP_V4L2)
|
||||
try:
|
||||
if not capture or not capture.isOpened():
|
||||
continue
|
||||
ok, _frame = capture.read()
|
||||
if ok:
|
||||
usable.append(str(index))
|
||||
finally:
|
||||
if capture:
|
||||
capture.release()
|
||||
|
||||
print(",".join(usable[:2]))
|
||||
PY
|
||||
}
|
||||
|
||||
is_wsl_environment() {
|
||||
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
|
||||
}
|
||||
|
||||
detect_windows_usbipd_camera_busids() {
|
||||
local line=""
|
||||
local busid=""
|
||||
|
||||
command -v usbipd.exe >/dev/null 2>&1 || return 0
|
||||
|
||||
usbipd.exe list 2>/dev/null | tr -d '\r' | while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*([0-9]+-[0-9]+)[[:space:]] ]] || continue
|
||||
busid="${match[1]}"
|
||||
case "$line" in
|
||||
*[Cc]amera*|*[Ww]ebcam*|*"USB Video"*|*UVC*|*摄像头*|*相机*)
|
||||
printf "%s|%s\n" "$busid" "$line"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
download_usbipd_win_msi() {
|
||||
local output_path="$1"
|
||||
local fallback_url="$2"
|
||||
|
||||
mkdir -p "$(dirname "$output_path")"
|
||||
|
||||
"$SCRIPT_DIR/.venv/bin/python" - "$output_path" "$fallback_url" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
output = Path(sys.argv[1])
|
||||
fallback_url = sys.argv[2]
|
||||
arch = "arm64" if "aarch64" in __import__("platform").machine().lower() or "arm64" in __import__("platform").machine().lower() else "x64"
|
||||
latest_api = "https://api.github.com/repos/dorssel/usbipd-win/releases/latest"
|
||||
|
||||
|
||||
def download(url: str) -> None:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "planet-bootstrap"})
|
||||
with urllib.request.urlopen(request, timeout=25) as response:
|
||||
output.write_bytes(response.read())
|
||||
|
||||
|
||||
try:
|
||||
request = urllib.request.Request(latest_api, headers={"User-Agent": "planet-bootstrap"})
|
||||
with urllib.request.urlopen(request, timeout=12) as response:
|
||||
release = json.loads(response.read().decode("utf-8"))
|
||||
assets = release.get("assets") or []
|
||||
url = next(
|
||||
(
|
||||
asset.get("browser_download_url")
|
||||
for asset in assets
|
||||
if isinstance(asset, dict)
|
||||
and str(asset.get("name", "")).endswith(".msi")
|
||||
and arch in str(asset.get("name", "")).lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not url:
|
||||
raise RuntimeError("latest release has no matching MSI asset")
|
||||
download(url)
|
||||
except Exception:
|
||||
download(fallback_url)
|
||||
PY
|
||||
}
|
||||
|
||||
install_usbipd_win_from_msi() {
|
||||
local msi_path="$1"
|
||||
local msi_win_path=""
|
||||
local script_path=""
|
||||
|
||||
msi_win_path="$(wslpath -w "$msi_path" 2>/dev/null || true)"
|
||||
if [ -z "$msi_win_path" ]; then
|
||||
log_warn "usbipd-win MSI 已下载,但无法转换 Windows 路径: ${msi_path}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-install.XXXXXX.ps1")"
|
||||
cat > "$script_path" <<EOF
|
||||
\$ErrorActionPreference = 'Stop'
|
||||
Start-Process msiexec.exe -Wait -ArgumentList '/i', '${msi_win_path}', '/qn', '/norestart'
|
||||
EOF
|
||||
|
||||
log_warn "准备请求管理员 PowerShell 安装 usbipd-win"
|
||||
if run_windows_admin_powershell_script "$script_path" "无法请求管理员 PowerShell 安装 usbipd-win"; then
|
||||
rm -f "$script_path" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -f "$script_path" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_usbipd_win_for_wsl() {
|
||||
local auto_install="${PLANET_MOTION_AGENT_USBIPD_AUTO_INSTALL:-1}"
|
||||
local fallback_url="$USBIPD_WIN_FALLBACK_URL_X64"
|
||||
local msi_path=""
|
||||
local winget_status=0
|
||||
|
||||
command -v usbipd.exe >/dev/null 2>&1 && return 0
|
||||
is_wsl_environment || return 1
|
||||
|
||||
case "$auto_install" in
|
||||
0|false|no|off)
|
||||
log_warn "未找到 usbipd.exe,且 PLANET_MOTION_AGENT_USBIPD_AUTO_INSTALL=0"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v powershell.exe >/dev/null 2>&1; then
|
||||
log_warn "未找到 powershell.exe,无法从 WSL 自动安装 usbipd-win"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_warn "未找到 usbipd.exe,准备尝试自动安装 usbipd-win"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "\
|
||||
\$ProgressPreference = 'SilentlyContinue'; \
|
||||
if (Get-Command usbipd -ErrorAction SilentlyContinue) { exit 0 }; \
|
||||
if (Get-Command winget -ErrorAction SilentlyContinue) { \
|
||||
winget install -e --id dorssel.usbipd-win --accept-package-agreements --accept-source-agreements --silent; \
|
||||
exit \$LASTEXITCODE \
|
||||
}; \
|
||||
exit 2" >/dev/null 2>&1 || winget_status=$?
|
||||
|
||||
if [ "$winget_status" -eq 0 ]; then
|
||||
hash -r 2>/dev/null || true
|
||||
if command -v usbipd.exe >/dev/null 2>&1; then
|
||||
log_success "usbipd-win 已通过 winget 安装"
|
||||
return 0
|
||||
fi
|
||||
log_warn "winget 已完成,但当前 WSL shell 暂未发现 usbipd.exe;继续尝试 MSI fallback"
|
||||
else
|
||||
log_warn "winget 安装 usbipd-win 未完成,准备下载 MSI fallback"
|
||||
fi
|
||||
|
||||
case "$(uname -m 2>/dev/null | tr '[:upper:]' '[:lower:]')" in
|
||||
aarch64|arm64) fallback_url="$USBIPD_WIN_FALLBACK_URL_ARM64" ;;
|
||||
esac
|
||||
msi_path="$USBIPD_WIN_DOWNLOAD_DIR/usbipd-win-${USBIPD_WIN_FALLBACK_VERSION}.msi"
|
||||
if [ ! -s "$msi_path" ]; then
|
||||
log_note "下载 usbipd-win MSI 到: ${msi_path}"
|
||||
if ! download_usbipd_win_msi "$msi_path" "$fallback_url"; then
|
||||
log_warn "usbipd-win MSI 下载失败"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if install_usbipd_win_from_msi "$msi_path"; then
|
||||
hash -r 2>/dev/null || true
|
||||
if command -v usbipd.exe >/dev/null 2>&1; then
|
||||
log_success "usbipd-win 已安装"
|
||||
return 0
|
||||
fi
|
||||
log_warn "usbipd-win 已安装,但当前 WSL shell 仍未发现 usbipd.exe;请重新打开终端后重试"
|
||||
else
|
||||
log_warn "usbipd-win 自动安装未完成;MSI 已保留在 ${msi_path}"
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
prepare_motion_agent_host_dependencies() {
|
||||
[ "${MOTION_AGENT_REQUESTED:-1}" -eq 1 ] || return 0
|
||||
is_wsl_environment || return 0
|
||||
|
||||
log_step "检查 Motion Agent WSL 宿主依赖"
|
||||
if command -v usbipd.exe >/dev/null 2>&1; then
|
||||
log_success "usbipd-win 已可用"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ensure_usbipd_win_for_wsl; then
|
||||
log_success "usbipd-win 已就绪"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "usbipd-win 自动准备未完成;普通前后端初始化继续"
|
||||
log_note "如果要使用 Windows USB 摄像头,请稍后重开终端或执行:"
|
||||
log_note " ./planet.sh restart --motion-agent-wsl-usbipd"
|
||||
return 0
|
||||
}
|
||||
|
||||
print_motion_agent_wsl_usbipd_manual_steps() {
|
||||
local busid_hint="${1:-<BUSID>}"
|
||||
|
||||
log_note "可在 Windows PowerShell 中查看并透传摄像头:"
|
||||
log_note " usbipd list"
|
||||
log_note " usbipd bind --busid ${busid_hint} # 需要管理员 PowerShell,仅首次共享时需要"
|
||||
log_note " usbipd attach --wsl --busid ${busid_hint}"
|
||||
log_note "透传后回到 WSL 执行:ls /dev/video*"
|
||||
}
|
||||
|
||||
request_motion_agent_wsl_usbipd_bind() {
|
||||
local busid="$1"
|
||||
local script_path=""
|
||||
|
||||
if ! [[ "$busid" =~ ^[0-9]+-[0-9]+$ ]]; then
|
||||
log_warn "usbipd BUSID 格式异常,跳过自动 bind: ${busid}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
script_path="$(mktemp "${TMPDIR:-/tmp}/planet-usbipd-bind.XXXXXX.ps1")"
|
||||
cat > "$script_path" <<EOF
|
||||
\$ErrorActionPreference = 'Stop'
|
||||
usbipd bind --busid '${busid}'
|
||||
EOF
|
||||
|
||||
log_warn "摄像头 ${busid} 尚未共享,准备弹出管理员 PowerShell 执行 usbipd bind"
|
||||
if run_windows_admin_powershell_script "$script_path" "无法请求管理员 PowerShell 执行 usbipd bind"; then
|
||||
rm -f "$script_path" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -f "$script_path" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
try_motion_agent_wsl_usbipd_camera() {
|
||||
local enabled="${MOTION_AGENT_WSL_USBIPD:-${PLANET_MOTION_AGENT_WSL_USBIPD:-0}}"
|
||||
local busid="${MOTION_AGENT_WSL_USBIPD_BUSID:-}"
|
||||
local candidates=""
|
||||
local candidate_count=0
|
||||
local candidate_line=""
|
||||
local attach_output=""
|
||||
local attach_status=0
|
||||
|
||||
case "$enabled" in
|
||||
1|true|yes|on) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
|
||||
is_wsl_environment || return 1
|
||||
|
||||
if ! ensure_usbipd_win_for_wsl; then
|
||||
log_warn "未找到可用 usbipd.exe,无法自动把 Windows USB 摄像头透传到 WSL"
|
||||
log_note "也可在 Windows 手动安装:winget install -e --id dorssel.usbipd-win"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$busid" ]; then
|
||||
candidates="$(detect_windows_usbipd_camera_busids || true)"
|
||||
candidate_count="$(printf "%s\n" "$candidates" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')"
|
||||
if [ "$candidate_count" -eq 1 ]; then
|
||||
candidate_line="$(printf "%s\n" "$candidates" | sed -n '1p')"
|
||||
busid="${candidate_line%%|*}"
|
||||
log_note "Motion Agent 在 Windows USB 设备中发现摄像头 BUSID: ${busid}"
|
||||
elif [ "$candidate_count" -gt 1 ]; then
|
||||
log_warn "发现多个疑似摄像头 USB 设备,脚本不会猜测使用哪一个"
|
||||
printf "%s\n" "$candidates" | sed 's/^/ /'
|
||||
log_note "请指定:./planet.sh restart -m --motion-agent-wsl-usbipd-busid <BUSID>"
|
||||
return 1
|
||||
else
|
||||
log_warn "usbipd-win 未列出明确的摄像头设备"
|
||||
print_motion_agent_wsl_usbipd_manual_steps
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "$busid" =~ ^[0-9]+-[0-9]+$ ]]; then
|
||||
log_warn "usbipd BUSID 格式异常,无法自动透传摄像头: ${busid}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_warn "准备通过 usbipd-win 将 Windows USB 摄像头 ${busid} 附加到 WSL"
|
||||
log_note "附加期间该摄像头通常会从 Windows 应用中暂时断开。"
|
||||
|
||||
attach_output="$(usbipd.exe attach --wsl --busid "$busid" 2>&1 | tr -d '\r')" || attach_status=$?
|
||||
if [ "$attach_status" -ne 0 ]; then
|
||||
case "$attach_output" in
|
||||
*"Device is not shared"*|*"not shared"*|*"usbipd bind"*)
|
||||
if request_motion_agent_wsl_usbipd_bind "$busid"; then
|
||||
log_note "usbipd bind 已完成,重试 attach 摄像头 ${busid}"
|
||||
attach_status=0
|
||||
attach_output="$(usbipd.exe attach --wsl --busid "$busid" 2>&1 | tr -d '\r')" || attach_status=$?
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$attach_status" -ne 0 ]; then
|
||||
log_warn "usbipd attach 未完成"
|
||||
[ -z "$attach_output" ] || printf "%s\n" "$attach_output" | sed 's/^/ /'
|
||||
print_motion_agent_wsl_usbipd_manual_steps "$busid"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
|
||||
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||||
log_success "Motion Agent 已通过 usbipd-win 在 WSL 中发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "usbipd attach 已执行,但 WSL 仍未发现 /dev/video*"
|
||||
print_motion_agent_wsl_usbipd_manual_steps "$busid"
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_motion_agent_live_deps() {
|
||||
local auto_install="${PLANET_MOTION_AGENT_AUTO_INSTALL:-1}"
|
||||
|
||||
@@ -2419,8 +2770,66 @@ collect_port_pids() {
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
pids="$(python3 - "$port" <<'PY' 2>/dev/null || true
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
target_port = int(sys.argv[1])
|
||||
target_hex = f"{target_port:04X}"
|
||||
inodes: set[str] = set()
|
||||
|
||||
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||
try:
|
||||
lines = open(table, "r", encoding="utf-8").read().splitlines()[1:]
|
||||
except OSError:
|
||||
continue
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 10:
|
||||
continue
|
||||
local_address = parts[1]
|
||||
state = parts[3]
|
||||
inode = parts[9]
|
||||
if state != "0A":
|
||||
continue
|
||||
try:
|
||||
_address_hex, port_hex = local_address.rsplit(":", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
if port_hex.upper() == target_hex:
|
||||
inodes.add(inode)
|
||||
|
||||
if not inodes:
|
||||
raise SystemExit(0)
|
||||
|
||||
for pid in filter(str.isdigit, os.listdir("/proc")):
|
||||
fd_dir = f"/proc/{pid}/fd"
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
continue
|
||||
for fd in fds:
|
||||
try:
|
||||
target = os.readlink(f"{fd_dir}/{fd}")
|
||||
except OSError:
|
||||
continue
|
||||
if target.startswith("socket:[") and target[8:-1] in inodes:
|
||||
print(pid)
|
||||
break
|
||||
PY
|
||||
)"
|
||||
if [ -n "$pids" ]; then
|
||||
printf "%s\n" "$pids" | awk '!seen[$0]++'
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
pids="$(ss -ltnp "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p' || true)"
|
||||
pids="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p; s/.*pid=([0-9]+),.*/\1/p' || true)"
|
||||
if [ -n "$pids" ]; then
|
||||
printf "%s\n" "$pids" | awk '!seen[$0]++'
|
||||
return 0
|
||||
@@ -2525,6 +2934,43 @@ PY
|
||||
fi
|
||||
}
|
||||
|
||||
print_port_bind_probe_details() {
|
||||
local port="$1"
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
|
||||
python3 - "$port" <<'PY' 2>/dev/null | while IFS= read -r line; do
|
||||
import socket
|
||||
import sys
|
||||
|
||||
port = int(sys.argv[1])
|
||||
for family, host, label in (
|
||||
(socket.AF_INET, "0.0.0.0", "IPv4 0.0.0.0"),
|
||||
(socket.AF_INET, "127.0.0.1", "IPv4 127.0.0.1"),
|
||||
(socket.AF_INET6, "::", "IPv6 ::"),
|
||||
(socket.AF_INET6, "::1", "IPv6 ::1"),
|
||||
):
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(family)
|
||||
if family == socket.AF_INET6 and hasattr(socket, "IPV6_V6ONLY"):
|
||||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||
sock.bind((host, port))
|
||||
print(f"bind probe: {label}:{port} ok")
|
||||
except OSError as exc:
|
||||
print(f"bind probe: {label}:{port} failed errno={getattr(exc, 'errno', 'unknown')} {exc}")
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
PY
|
||||
[ -n "$line" ] || continue
|
||||
printf "${DIM} %s${NC}\n" "$line"
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_port_release() {
|
||||
local port="$1"
|
||||
local max_attempts="${2:-$PORT_RELEASE_ATTEMPTS}"
|
||||
@@ -2746,6 +3192,7 @@ EOF
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
log_note "未能在当前环境内定位端口 ${port} 的监听进程,可能被宿主机或外部网络命名空间占用。"
|
||||
print_port_bind_probe_details "$port" || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -2971,20 +3418,20 @@ parse_service_args() {
|
||||
MOTION_AGENT_PORT="$DEFAULT_MOTION_AGENT_PORT"
|
||||
MOTION_AGENT_CAMERA_INDEXES="${MOTION_AGENT_CAMERA_INDEXES:-}"
|
||||
MOTION_AGENT_CAMERA_URLS="${MOTION_AGENT_CAMERA_URLS:-}"
|
||||
MOTION_AGENT_MODE="${MOTION_AGENT_MODE:-auto}"
|
||||
MOTION_AGENT_WSL_USBIPD="${MOTION_AGENT_WSL_USBIPD:-${PLANET_MOTION_AGENT_WSL_USBIPD:-0}}"
|
||||
MOTION_AGENT_WSL_USBIPD_BUSID="${MOTION_AGENT_WSL_USBIPD_BUSID:-}"
|
||||
BACKEND_PORT_REQUESTED=0
|
||||
FRONTEND_PORT_REQUESTED=0
|
||||
AI_PROVIDER_REQUESTED=0
|
||||
MOTION_AGENT_REQUESTED=0
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=0
|
||||
MOTION_AGENT_DISABLED=0
|
||||
MOTION_AGENT_DRY_RUN=0
|
||||
DATABASE_REQUESTED=0
|
||||
FRONTEND_LAN_ENABLED=0
|
||||
FRONTEND_LAN_HTTPS_ENABLED=0
|
||||
|
||||
case "${PLANET_START_MOTION_AGENT:-0}" in
|
||||
1|true|yes|on)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
;;
|
||||
esac
|
||||
case "${MOTION_AGENT_DRY_RUN:-0}" in
|
||||
1|true|yes|on)
|
||||
MOTION_AGENT_DRY_RUN=1
|
||||
@@ -3022,10 +3469,19 @@ parse_service_args() {
|
||||
;;
|
||||
-m|--motion-agent)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
shift 1
|
||||
;;
|
||||
--non-motion-agent)
|
||||
MOTION_AGENT_REQUESTED=0
|
||||
MOTION_AGENT_DISABLED=1
|
||||
shift 1
|
||||
;;
|
||||
--motion-agent-port)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||||
MOTION_AGENT_PORT="$2"
|
||||
shift 2
|
||||
@@ -3036,11 +3492,15 @@ parse_service_args() {
|
||||
;;
|
||||
--motion-agent-dry-run)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
MOTION_AGENT_DRY_RUN=1
|
||||
shift 1
|
||||
;;
|
||||
--motion-agent-camera-indexes)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
if [ -n "$2" ]; then
|
||||
MOTION_AGENT_CAMERA_INDEXES="$2"
|
||||
shift 2
|
||||
@@ -3051,6 +3511,8 @@ parse_service_args() {
|
||||
;;
|
||||
--motion-agent-camera-urls)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
if [ -n "$2" ]; then
|
||||
MOTION_AGENT_CAMERA_URLS="$2"
|
||||
shift 2
|
||||
@@ -3059,6 +3521,38 @@ parse_service_args() {
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--motion-agent-mode)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
if [ -n "$2" ] && [[ "$2" =~ ^(auto|single|dual|dual_redundant|single_fallback|calibrated_3d)$ ]]; then
|
||||
MOTION_AGENT_MODE="$2"
|
||||
shift 2
|
||||
else
|
||||
log_error "--motion-agent-mode 需要 auto/single/dual/dual_redundant/single_fallback/calibrated_3d"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--motion-agent-wsl-usbipd)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
MOTION_AGENT_WSL_USBIPD=1
|
||||
shift 1
|
||||
;;
|
||||
--motion-agent-wsl-usbipd-busid)
|
||||
MOTION_AGENT_REQUESTED=1
|
||||
MOTION_AGENT_EXPLICIT_REQUESTED=1
|
||||
MOTION_AGENT_DISABLED=0
|
||||
MOTION_AGENT_WSL_USBIPD=1
|
||||
if [ -n "$2" ]; then
|
||||
MOTION_AGENT_WSL_USBIPD_BUSID="$2"
|
||||
shift 2
|
||||
else
|
||||
log_error "--motion-agent-wsl-usbipd-busid 需要 usbipd list 中的 BUSID,例如 3-2"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
-d|--database)
|
||||
DATABASE_REQUESTED=1
|
||||
shift 1
|
||||
@@ -3188,6 +3682,33 @@ print_process_health_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
print_motion_agent_health_status() {
|
||||
local detail=""
|
||||
local tracked_pid=""
|
||||
|
||||
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || {
|
||||
[ -f "$MOTION_AGENT_PID_FILE" ] &&
|
||||
tracked_pid="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)" &&
|
||||
[ -n "$tracked_pid" ] &&
|
||||
kill -0 "$tracked_pid" 2>/dev/null
|
||||
}; then
|
||||
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f "$MOTION_AGENT_SKIPPED_FILE" ]; then
|
||||
detail="$(sed -n 's/^reason=//p' "$MOTION_AGENT_SKIPPED_FILE" 2>/dev/null | head -n 1)"
|
||||
if [ -n "$detail" ]; then
|
||||
echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC} ${DIM}(${detail})${NC}"
|
||||
else
|
||||
echo -e "${DIM} Motion Agent:${NC} ${YELLOW}skipped${NC}"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
|
||||
}
|
||||
|
||||
motion_agent_pids() {
|
||||
local pids=""
|
||||
|
||||
@@ -3214,6 +3735,73 @@ cleanup_motion_agent_processes() {
|
||||
remove_pid_file "$MOTION_AGENT_PID_FILE"
|
||||
}
|
||||
|
||||
mark_motion_agent_skipped() {
|
||||
local reason="$1"
|
||||
local detail="${2:-}"
|
||||
|
||||
mkdir -p "$PLANET_STATE_DIR"
|
||||
{
|
||||
printf "status=skipped\n"
|
||||
printf "time=%s\n" "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
printf "reason=%s\n" "$reason"
|
||||
if [ -n "$detail" ]; then
|
||||
printf "detail=%s\n" "$detail"
|
||||
fi
|
||||
} > "$MOTION_AGENT_SKIPPED_FILE"
|
||||
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=1
|
||||
remove_pid_file "$MOTION_AGENT_PID_FILE"
|
||||
log_warn "Motion Agent skipped: ${reason}"
|
||||
if [ -n "$detail" ]; then
|
||||
log_note "$detail"
|
||||
fi
|
||||
}
|
||||
|
||||
clear_motion_agent_skipped_state() {
|
||||
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0
|
||||
rm -f "$MOTION_AGENT_SKIPPED_FILE"
|
||||
}
|
||||
|
||||
release_motion_agent_port_for_start() {
|
||||
local port="$1"
|
||||
local pids=""
|
||||
local pid=""
|
||||
|
||||
cleanup_motion_agent_processes TERM
|
||||
if wait_for_port_release "$port" 25 0.2; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "Motion Agent 端口 ${port} 清理后仍不可绑定,尝试强制清理"
|
||||
cleanup_motion_agent_processes KILL
|
||||
if wait_for_port_release "$port" 10 0.2; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
pids="$(collect_port_pids "$port" || true)"
|
||||
if [ -n "$pids" ]; then
|
||||
log_warn "发现 Motion Agent 端口 ${port} 仍有监听进程,正在强制终止"
|
||||
for pid in $pids; do
|
||||
terminate_process_group KILL "$pid"
|
||||
terminate_process_tree KILL "$pid"
|
||||
done
|
||||
if wait_for_port_release "$port" 10 0.2; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if force_cleanup_external_port_listener "$port" "Motion Agent"; then
|
||||
if wait_for_port_release "$port" 10 0.2; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_port_listener_details "$port"
|
||||
mark_motion_agent_skipped \
|
||||
"端口 ${port} 被外部环境占用,已跳过 Motion Agent 启动" \
|
||||
"其他服务会继续启动;需要动捕时请释放端口或改用 --motion-agent-port <端口>。"
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_motion_agent_ready() {
|
||||
local port="$1"
|
||||
local pid="$2"
|
||||
@@ -3238,51 +3826,95 @@ start_motion_agent_service() {
|
||||
local dry_run="$2"
|
||||
local lan_enabled="${3:-0}"
|
||||
local bind_host="127.0.0.1"
|
||||
local auto_detected_camera_indexes=0
|
||||
local validated_camera_indexes=""
|
||||
local -a motion_args
|
||||
|
||||
clear_motion_agent_skipped_state
|
||||
ensure_uv_backend_deps
|
||||
if [ "$dry_run" -eq 0 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ] && [ -z "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||||
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
|
||||
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||||
auto_detected_camera_indexes=1
|
||||
log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
|
||||
else
|
||||
if is_wsl_environment; then
|
||||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
|
||||
log_note "当前看起来是 WSL;Windows 摄像头通常不会自动出现在 /dev/video*。"
|
||||
log_note "真实识别请选择一种方式:"
|
||||
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
|
||||
log_note " 2. 用 usbipd-win 把 USB 摄像头透传到 WSL,再重试"
|
||||
log_note " 3. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run"
|
||||
case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in
|
||||
1|true|yes|on)
|
||||
log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1,降级为 dry-run"
|
||||
dry_run=1
|
||||
;;
|
||||
*)
|
||||
MOTION_AGENT_CAMERA_MISSING=1
|
||||
;;
|
||||
esac
|
||||
if try_motion_agent_wsl_usbipd_camera; then
|
||||
auto_detected_camera_indexes=1
|
||||
:
|
||||
else
|
||||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
|
||||
log_note "当前看起来是 WSL;Windows 摄像头通常不会自动出现在 /dev/video*。"
|
||||
log_note "真实识别请选择一种方式:"
|
||||
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
|
||||
log_note " 2. 用 --motion-agent-wsl-usbipd 尝试通过 usbipd-win 自动透传唯一 USB 摄像头"
|
||||
log_note " 3. 用 --motion-agent-wsl-usbipd-busid <BUSID> 指定 usbipd list 中的摄像头"
|
||||
log_note " 4. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run"
|
||||
case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in
|
||||
1|true|yes|on)
|
||||
log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1,降级为 dry-run"
|
||||
dry_run=1
|
||||
;;
|
||||
*)
|
||||
if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then
|
||||
log_warn "默认启动 Motion Agent 未找到摄像头,降级为 dry-run 协议服务"
|
||||
dry_run=1
|
||||
else
|
||||
MOTION_AGENT_CAMERA_MISSING=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0"
|
||||
if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then
|
||||
log_warn "默认启动 Motion Agent 未自动发现摄像头,降级为 dry-run 协议服务"
|
||||
dry_run=1
|
||||
else
|
||||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ "${MOTION_AGENT_CAMERA_MISSING:-0}" -eq 1 ]; then
|
||||
log_error "Motion Agent live 模式缺少可用摄像头"
|
||||
log_note "WSL 示例:./planet.sh restart -m --motion-agent-camera-urls http://<手机IP>:8080/video"
|
||||
log_note "WSL USB 示例:./planet.sh restart -m --motion-agent-wsl-usbipd"
|
||||
log_note "或仅调试协议:./planet.sh restart -m --motion-agent-dry-run"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$dry_run" -eq 0 ]; then
|
||||
ensure_motion_agent_live_deps
|
||||
if [ "$auto_detected_camera_indexes" -eq 1 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ]; then
|
||||
validated_camera_indexes="$(detect_motion_agent_camera_indexes_with_cv2 || true)"
|
||||
if [ -n "$validated_camera_indexes" ]; then
|
||||
if [ "$validated_camera_indexes" != "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||||
log_warn "Motion Agent 过滤不可打开的摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES} -> ${validated_camera_indexes}"
|
||||
fi
|
||||
MOTION_AGENT_CAMERA_INDEXES="$validated_camera_indexes"
|
||||
else
|
||||
if [ "${MOTION_AGENT_EXPLICIT_REQUESTED:-0}" -eq 0 ]; then
|
||||
log_warn "默认启动 Motion Agent 未找到可读帧摄像头,降级为 dry-run 协议服务"
|
||||
dry_run=1
|
||||
MOTION_AGENT_CAMERA_INDEXES=""
|
||||
else
|
||||
log_error "Motion Agent 未找到可打开并能读帧的摄像头 index"
|
||||
log_note "可手动指定:--motion-agent-camera-indexes <index>"
|
||||
log_note "WSL 下可改用:--motion-agent-camera-urls http://<手机IP>:8080/video"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if ! release_motion_agent_port_for_start "$motion_agent_port"; then
|
||||
return 0
|
||||
fi
|
||||
cleanup_motion_agent_processes TERM
|
||||
wait_for_port_release "$motion_agent_port" 10 0.2 || true
|
||||
|
||||
if ! can_bind_port "$motion_agent_port"; then
|
||||
log_error "Motion Agent 端口已被占用: ${motion_agent_port}"
|
||||
print_port_listener_details "$motion_agent_port"
|
||||
exit 1
|
||||
mark_motion_agent_skipped \
|
||||
"端口 ${motion_agent_port} 已被占用" \
|
||||
"未能释放 Motion Agent 端口,已跳过动捕服务;其他服务继续启动。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
: > "$MOTION_AGENT_LOG_FILE"
|
||||
@@ -3290,7 +3922,7 @@ start_motion_agent_service() {
|
||||
bind_host="0.0.0.0"
|
||||
fi
|
||||
|
||||
motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port")
|
||||
motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port" --mode "$MOTION_AGENT_MODE")
|
||||
if [ -n "$MOTION_AGENT_CAMERA_URLS" ]; then
|
||||
motion_args+=(--camera-urls "$MOTION_AGENT_CAMERA_URLS")
|
||||
elif [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||||
@@ -3315,6 +3947,7 @@ start_motion_agent_service() {
|
||||
print_port_listener_details "$motion_agent_port"
|
||||
log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。"
|
||||
log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
|
||||
log_note "输入模式可用 --motion-agent-mode auto/single/dual_redundant/single_fallback 指定。"
|
||||
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
|
||||
log_note "依赖缺失时脚本会自动执行 uv add mediapipe opencv-python。"
|
||||
cleanup_motion_agent_processes TERM
|
||||
@@ -3326,6 +3959,7 @@ stop_motion_agent_service() {
|
||||
cleanup_motion_agent_processes TERM
|
||||
log_halt "Motion Agent 已停止"
|
||||
fi
|
||||
clear_motion_agent_skipped_state
|
||||
}
|
||||
|
||||
cleanup_failed_start() {
|
||||
@@ -3467,6 +4101,8 @@ guard_init_when_services_running() {
|
||||
}
|
||||
|
||||
init() {
|
||||
parse_service_args "$@"
|
||||
|
||||
if ! guard_init_when_services_running; then
|
||||
return 0
|
||||
fi
|
||||
@@ -3492,6 +4128,8 @@ init() {
|
||||
ensure_planet_env_files
|
||||
log_success "环境变量文件已就绪"
|
||||
|
||||
prepare_motion_agent_host_dependencies
|
||||
|
||||
start_wait_session "启动数据库服务"
|
||||
ensure_database_services_healthy
|
||||
stop_wait_session
|
||||
@@ -3704,7 +4342,9 @@ start() {
|
||||
typeset -g STARTED_FRONTEND_THIS_RUN=1
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
|
||||
typeset -g STARTED_MOTION_AGENT_THIS_RUN=1
|
||||
if [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then
|
||||
typeset -g STARTED_MOTION_AGENT_THIS_RUN=1
|
||||
fi
|
||||
fi
|
||||
|
||||
local frontend_scheme=""
|
||||
@@ -3719,7 +4359,7 @@ start() {
|
||||
log_note "智能星球仪表盘: ${frontend_scheme}://localhost:${FRONTEND_PORT}/admin"
|
||||
log_note "AI Playground: ${frontend_scheme}://localhost:${FRONTEND_PORT}/playground"
|
||||
log_note "智能星球文档: ${frontend_scheme}://localhost:${FRONTEND_PORT}/docs"
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ] && [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then
|
||||
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
|
||||
fi
|
||||
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||
@@ -3878,7 +4518,7 @@ restart() {
|
||||
local state_ai_provider_port=""
|
||||
local state_motion_agent_port=""
|
||||
|
||||
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
||||
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
||||
stop_local_services_for_restart
|
||||
sleep 1
|
||||
start "$@"
|
||||
@@ -3912,9 +4552,10 @@ restart() {
|
||||
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT"
|
||||
fi
|
||||
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
stop_motion_agent_service
|
||||
sleep 1
|
||||
typeset -g MOTION_AGENT_SKIPPED_THIS_RUN=0
|
||||
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
|
||||
fi
|
||||
|
||||
@@ -3933,7 +4574,7 @@ restart() {
|
||||
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
|
||||
state_ai_provider_port="$AI_PROVIDER_PORT"
|
||||
fi
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
state_motion_agent_port="$MOTION_AGENT_PORT"
|
||||
fi
|
||||
local frontend_scheme=""
|
||||
@@ -3957,7 +4598,7 @@ restart() {
|
||||
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" "$frontend_scheme"
|
||||
fi
|
||||
fi
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
if [ "$MOTION_AGENT_EXPLICIT_REQUESTED" -eq 1 ] && [ "$MOTION_AGENT_REQUESTED" -eq 1 ] && [ "${MOTION_AGENT_SKIPPED_THIS_RUN:-0}" -eq 0 ]; then
|
||||
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
|
||||
fi
|
||||
}
|
||||
@@ -3979,7 +4620,7 @@ health() {
|
||||
print_http_health_status "后端" "http://localhost:${backend_port}/health"
|
||||
print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health"
|
||||
print_frontend_health_status "$frontend_port"
|
||||
print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE"
|
||||
print_motion_agent_health_status
|
||||
}
|
||||
|
||||
log() {
|
||||
@@ -4041,7 +4682,8 @@ set -- "${GLOBAL_ARG_REMAINDER[@]}"
|
||||
|
||||
case "$1" in
|
||||
init)
|
||||
init
|
||||
shift
|
||||
init "$@"
|
||||
;;
|
||||
start)
|
||||
shift
|
||||
@@ -4070,10 +4712,10 @@ case "$1" in
|
||||
log_error "用法: ./planet.sh {init|start|stop|destroy|restart|createuser|health|log}"
|
||||
log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志"
|
||||
log_note "init 首次初始化空项目: 同步 uv/bun 依赖、生成缺失 env、启动数据库并写入默认数据"
|
||||
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose"
|
||||
log_note "start 启动服务,默认包含 Motion Agent;可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --non-motion-agent -m/--motion-agent --motion-agent-port <端口> --motion-agent-mode auto|single|dual_redundant|single_fallback --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-wsl-usbipd --motion-agent-wsl-usbipd-busid <BUSID> --motion-agent-dry-run --allow-lan --verbose"
|
||||
log_note "stop 停止服务"
|
||||
log_note "destroy 删除 Planet 容器、卷、镜像和本地编译状态,执行前需要输入 Y 确认"
|
||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"
|
||||
log_note "restart 重启服务,默认包含 Motion Agent;可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] --non-motion-agent -m [Motion Agent] -d --allow-lan --verbose"
|
||||
log_note "createuser 交互创建用户"
|
||||
log_note "health 检查健康状态"
|
||||
log_note "log 查看日志"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.70.0"
|
||||
version = "0.71.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user