diff --git a/README.md b/README.md index 7fc85c9..a92d1fe 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,8 @@ python -m compileall led_platform tests node --check led_platform/web/static/output/main.js node --check led_platform/web/static/admin/main.js ``` + +## 文档 + +- [使用文档](docs/usage.md) +- [生产架构说明](docs/production-architecture.md) diff --git a/config/scenes.json b/config/scenes.json index 51e1f1d..97427f5 100644 --- a/config/scenes.json +++ b/config/scenes.json @@ -24,6 +24,30 @@ "view": "security", "description": "\u89c6\u9891\u5899\u3001\u544a\u8b66\u3001\u5730\u56fe\u8054\u52a8\u573a\u666f", "preload": ["camera-grid", "security-map"] + }, + { + "id": "command", + "name": "\u6307\u6325\u8c03\u5ea6", + "url": "/wall-app", + "view": "command", + "description": "\u4e8b\u4ef6\u6307\u6325\u3001\u4efb\u52a1\u6d3e\u53d1\u548c\u8de8\u533a\u534f\u540c\u573a\u666f", + "preload": ["command-network", "dispatch-tasks"] + }, + { + "id": "transport", + "name": "\u4ea4\u901a\u8fd0\u884c", + "url": "/wall-app", + "view": "transport", + "description": "\u8f66\u6d41\u3001\u8def\u7f51\u3001\u7ad9\u70b9\u8fd0\u884c\u548c\u8fd0\u529b\u8c03\u5ea6\u573a\u666f", + "preload": ["traffic-flow", "station-load"] + }, + { + "id": "dataflow", + "name": "\u6570\u636e\u4e2d\u67a2", + "url": "/wall-app", + "view": "dataflow", + "description": "\u6570\u636e\u6d41\u5165\u3001\u8ba1\u7b97\u96c6\u7fa4\u3001AI \u5206\u6790\u548c\u8f93\u51fa\u6307\u6807\u573a\u666f", + "preload": ["data-pipeline", "ai-cluster"] } ] } diff --git a/docs/production-architecture.md b/docs/production-architecture.md index 0af72fe..d680305 100644 --- a/docs/production-architecture.md +++ b/docs/production-architecture.md @@ -2,48 +2,43 @@ ## 目标 -将一个前端大屏应用稳定投放到 `14880 x 3510` LED 大屏。 +本项目用于把一个逻辑尺寸为 `14880 x 3510` 的 LED 大屏内容,拆分到两台 GPU 输出服务器本地渲染。 -硬件约束: +生产形态: -- 两台 GPU 服务器。 -- 每台服务器 4 路 4K 输出。 -- 两台服务器品牌或 GPU 可能不同,无法依赖硬件级 GPU 同步。 +- 控制服务运行 FastAPI,负责场景、动作、时间同步、ACK、状态监控。 +- 左 GPU 服务器打开 `/output/left`,只显示左半屏 tile。 +- 右 GPU 服务器打开 `/output/right`,只显示右半屏 tile。 +- 每台 GPU 服务器输出 4 路 4K 到拼控或 LED 控制器。 +- 拼控或 LED 控制器完成最终物理拼接。 -因此系统目标不是让两张 GPU 每一帧硬同步,而是: +WebSocket 不传大视频流,只传状态、命令、时间、ACK 和性能指标。画面由两台 GPU 服务器本地渲染。 -- 统一业务状态。 -- 统一提交时间。 -- 统一动画时间轴。 -- 输出端本地渲染。 -- 拼控完成物理拼接和输入对齐。 - -## 推荐架构 +## 逻辑拓扑 ```text -控制服务 FastAPI - - 场景管理 - - WebSocket 同步 - - 时间校准 - - ACK 追踪 - - 性能监控 - -左 GPU 服务器 - - 打开 /output/left - - 渲染完整大屏应用的 left tile - - 4 路 4K 输出到拼控 - -右 GPU 服务器 - - 打开 /output/right - - 渲染完整大屏应用的 right tile - - 4 路 4K 输出到拼控 - -拼控 / LED 控制器 - - 接收 8 路 4K - - 按物理坐标拼接成 14880 x 3510 + 控制台 / + http://control:8000/ + | + v + FastAPI 控制服务 + 场景管理 / WebSocket Hub / SyncCoordinator / 状态接口 + | | + prepare/commit/action status/telemetry/ACK + | | + ------------------------------- + | | + v v + 左 GPU 输出服务器 右 GPU 输出服务器 + /output/left /output/right + left tile 本地渲染 right tile 本地渲染 + 4 路 4K 输出 4 路 4K 输出 + | | + ---------> 拼控 / LED 控制器 <- + 14880 x 3510 ``` -## 左右屏如何分开 +## Tile 切分 完整逻辑画面: @@ -56,13 +51,19 @@ ```text left: - x=0, y=0, width=7440, height=3510 + x=0 + y=0 + width=7440 + height=3510 right: - x=7440, y=0, width=7440, height=3510 + x=7440 + y=0 + width=7440 + height=3510 ``` -输出端页面创建完整逻辑大屏坐标系,然后根据自己的 tile 做视口偏移。真实 Three.js 项目中,应使用: +输出端页面使用完整逻辑大屏坐标系,再根据自己的 tile 做视口偏移。Three.js 项目中建议使用: ```js camera.setViewOffset( @@ -71,19 +72,19 @@ camera.setViewOffset( tile.x, tile.y, tile.width, - tile.height + tile.height, ); ``` ## 每台服务器 4 路 4K -每台服务器建议配置为 `2 x 2` 逻辑桌面: +每台 GPU 服务器建议配置为 `2 x 2` 逻辑桌面: ```text 7680 x 4320 ``` -四路输出: +四路输出映射: ```text 1: x=0, y=0, 3840 x 2160 @@ -96,35 +97,50 @@ camera.setViewOffset( ## 同步协议 -场景切换: +### 场景切换 + +控制台调用: ```text POST /api/scenes/{scene_id}/switch ``` -服务端广播: +服务端生成同一个 `command_id`,先广播: ```text -prepare_scene(command_id, apply_at_ms) -commit_scene(command_id, apply_at_ms) +prepare_scene(command_id, apply_at_ms, state, scene) ``` -输出端流程: +输出端收到 `prepare_scene` 后: + +1. 预加载资源。 +2. 等待至少两个 RAF,让浏览器完成布局和首帧准备。 +3. 如果存在 `window.ledPlatformPrepareScene`,等待业务方的真实预渲染/超分完成。 +4. 回 ACK:`status=prepared`。 + +服务端 `SyncCoordinator` 等 `target_tiles` 中的 `left` 和 `right` 都返回 `prepared` 后,才广播: ```text -1. 收到 prepare,记录命令,可预加载资源。 -2. 收到 commit,等到 apply_at_ms。 -3. 到点提交场景。 -4. 回 ACK。 +commit_scene(command_id, apply_at_ms, state, scene) ``` -局部动作: +输出端收到 `commit_scene` 后,等到统一的 `apply_at_ms` 再切换画面,并回 ACK: + +```text +status=committed +``` + +这样谁渲染慢就等谁,两个输出端都准备好后才同步放行。 + +### 局部动作 + +控制台调用: ```text POST /api/actions ``` -支持结构化动作: +支持动作: ```text page.next @@ -135,37 +151,93 @@ timeline.pause timeline.resume ``` -## 关键渲染原则 +局部动作目前直接按 `apply_at_ms` 调度并回 ACK: -所有动画、Three.js、地图和视频都应基于统一时间轴: +```text +status=action_committed +``` + +如果后续某些局部动作也需要超分或重资源准备,可以复用场景切换的 prepare/barrier/commit 模式。 + +## 画面回显 + +控制台内置两个只读预览 iframe,并把它们无缝拼成一块完整画面: + +```text +/output/left?preview=1 +/output/right?preview=1 +``` + +预览模式通过 `/ws/admin` 接收状态和命令,不连接 `/ws/output/{tile_id}`,因此: + +- 不注册为真实输出节点。 +- 不发送 `prepared`、`committed`、`telemetry` ACK。 +- 不会提前释放同步 barrier。 +- 只用于控制台观察画面。 + +真实生产输出端仍然打开: + +```text +/output/left +/output/right +``` + +## 时间同步 + +输出端通过 `clock_ping` / `clock_pong` 估算服务端时间: + +```text +serverNowMs = Date.now() + serverOffsetFromDateMs +``` + +RTT 使用 `performance.now()` 估算,服务端时间偏移使用客户端 wall clock 的发送时间和返回时间中点估算。 + +渲染和动画应基于统一时间轴: ```js const t = serverNowMs() - sceneStartedAtMs; renderSceneAt(t); ``` -不要让左右服务器各自自由播放: +不要让左右输出端各自累计本地 delta: ```js // 不推荐 animation += localDeltaTime; ``` -这样即使某台机器偶尔慢一帧,也会在下一帧追到统一时间,不会越播越偏。 +## 状态观测 -## 为什么不推荐单机渲染后网络分发 +控制台显示: -单侧半屏未压缩数据量: +- 输出节点数量。 +- 每个输出节点 FPS、frame time、RTT、clock offset。 +- 最近命令回显。 +- 每个 tile 的 `prepared`、`committed`、`late`、`error` 状态。 +- barrier 放行耗时。 + +接口: ```text -7440 x 3510 x 4 bytes x 60fps ~= 6.3 GB/s +GET /api/sync/status +GET /healthz ``` -左右两侧合计超过 `12 GB/s`。普通网络视频流需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。 +## 为什么不传视频流 + +单侧半屏未压缩数据量约为: + +```text +7440 x 3510 x 4 bytes x 60 fps ~= 6.3 GB/s +``` + +左右两侧合计超过 `12 GB/s`。普通网络视频流还需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。 + +本系统选择“控制服务发命令,输出端本地渲染”,更适合高分辨率 LED 墙。 ## 生产验收指标 -建议关注 P95/P99,而不是平均值: +建议关注 P95/P99: ```text 60 FPS: @@ -182,6 +254,7 @@ animation += localDeltaTime; ```text WebSocket RTT < 10ms clock offset < 5ms +left/right 都返回 prepared left/right 都返回 committed 或 action_committed 不得频繁出现 late ACK ``` @@ -199,12 +272,13 @@ left/right 都返回 committed 或 action_committed 能保证: - 两边业务状态一致。 -- 两边按同一服务端时间点提交。 +- 两边都准备好后才释放场景提交。 +- 两边按统一服务端时间点提交。 - 动画长期不漂移。 - 命令和性能可观测。 不能单独保证: -- 两张不同 GPU 每一帧物理扫描完全同相。 +- 两张不同 GPU 的物理扫描完全同相。 如果必须达到广播级帧同步,需要硬件层支持 Genlock / Frame Lock / 专业视频墙控制器。 diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..dbd7596 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,279 @@ +# 使用文档 + +## 1. 环境要求 + +- Python `3.10` 到 `3.12` 推荐。 +- macOS 本机如果没有 `python` 命令,使用 `python3` 或明确的 Python 3.12 路径。 +- 浏览器建议使用 Chrome / Chromium / Edge。 +- 生产输出服务器建议关闭休眠、屏保和自动更新弹窗。 + +当前项目依赖见: + +```text +requirements.txt +pyproject.toml +``` + +## 2. 首次启动 + +在项目根目录执行: + +```bash +cd /Users/tjy/Documents/code/work/dp +/Users/tjy/.local/bin/python3.12 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +.venv/bin/python -m led_platform.cli serve +``` + +服务默认监听: + +```text +0.0.0.0:8000 +``` + +如果要指定端口: + +```bash +.venv/bin/python -m led_platform.cli serve --host 0.0.0.0 --port 8000 +``` + +## 3. 日常启动 + +```bash +cd /Users/tjy/Documents/code/work/dp +.venv/bin/python -m led_platform.cli serve +``` + +查看本机局域网地址: + +```bash +.venv/bin/python -m led_platform.cli urls +``` + +也可以直接查看: + +```bash +ipconfig getifaddr en0 +``` + +## 4. 常用地址 + +本机访问: + +```text +控制台: http://127.0.0.1:8000/ +左输出: http://127.0.0.1:8000/output/left +右输出: http://127.0.0.1:8000/output/right +健康检查: http://127.0.0.1:8000/healthz +同步状态: http://127.0.0.1:8000/api/sync/status +API 文档: http://127.0.0.1:8000/docs +``` + +局域网访问时,把 `127.0.0.1` 换成控制服务 IP,例如: + +```text +http://192.168.0.182:8000/ +``` + +## 5. 控制台使用 + +打开: + +```text +http://控制服务IP:8000/ +``` + +控制台包含: + +- 画面回显:把 left/right 两个只读预览无缝拼成一块完整画面,桌面布局下位于控制台顶部。 +- 场景切换:切换 overview、energy、security。 +- 输出与同步:查看输出端连接数量、FPS、frame time、RTT、clock offset。 +- 生产命令回显:查看真实 `/output/left` 和 `/output/right` 的 prepared、放行、committed 状态。 +- 局部动作:翻页、能源模式、告警、暂停/恢复时间轴。 + +画面回显使用: + +```text +/output/left?preview=1 +/output/right?preview=1 +``` + +控制台会把两个预览 iframe 贴在一起,形成 `14880 x 3510` 的完整大屏回显。它是只读预览,不会参与真实输出端 ACK。所以上方画面变化只代表控制台预览已更新;生产命令回显里的等待、放行和完成,代表真实输出端是否已经 ACK。 + +## 6. 生产输出端使用 + +左 GPU 输出服务器打开: + +```text +http://控制服务IP:8000/output/left +``` + +右 GPU 输出服务器打开: + +```text +http://控制服务IP:8000/output/right +``` + +输出端打开后会: + +1. 请求 `/api/bootstrap/{tile_id}` 获取 tile、场景和当前状态。 +2. 连接 `/ws/output/{tile_id}`。 +3. 上报 `hello`,注册为真实输出节点。 +4. 定时进行时钟同步。 +5. 定时上报 FPS、frame time、dropped frames。 +6. 收到场景命令后执行 prepare/barrier/commit。 + +## 7. 场景切换流程 + +控制台点击“切换”后: + +1. 后端生成新场景状态和 `command_id`。 +2. 后端广播 `prepare_scene`。 +3. left/right 真实输出端预加载或预渲染。 +4. left/right 都回 `prepared`。 +5. 后端记录 `released_at_ms` 并广播 `commit_scene`。 +6. left/right 等到 `apply_at_ms` 同步显示。 +7. left/right 回 `committed` 或 `late`。 + +在控制台“命令回显”里可以看到每个阶段。 + +## 8. 接入真实超分渲染 + +输出端预留了 hook: + +```js +window.ledPlatformPrepareScene = async ({ commandId, tile, state, scene }) => { + // 1. 根据 scene/state 准备真实资源 + // 2. 执行超分或离屏渲染 + // 3. 确认下一次 commit 可以无卡顿显示 +}; +``` + +只要这个 Promise 不 resolve,输出端就不会发送 `prepared`。后端也就不会释放 `commit_scene`。 + +如果超分失败,输出端会回: + +```text +status=error +``` + +## 9. 配置文件 + +场景配置: + +```text +config/scenes.json +``` + +字段: + +```text +id 场景 ID +name 控制台显示名 +url 场景 URL,目前示例都使用 /wall-app +view 输出端内部视图名 +description 描述 +preload 准备阶段预加载资源 +``` + +Tile 配置: + +```text +config/tiles.json +``` + +字段: + +```text +id left / right +x, y 在完整逻辑大屏中的起点 +width, height tile 尺寸 +wall_width 完整大屏宽度 +wall_height 完整大屏高度 +desktop_width 单台 GPU 服务器桌面宽度 +desktop_height 单台 GPU 服务器桌面高度 +physical_outputs 4 路 4K 输出映射 +``` + +环境变量前缀: + +```text +LED_ +``` + +示例: + +```bash +LED_APP_PORT=9000 .venv/bin/python -m led_platform.cli serve +LED_SWITCH_PREPARE_DELAY_MS=3000 .venv/bin/python -m led_platform.cli serve +``` + +## 10. 验证命令 + +```bash +.venv/bin/python -m pytest -q +.venv/bin/python -m compileall led_platform tests +node --check led_platform/web/static/output/main.js +node --check led_platform/web/static/admin/main.js +``` + +健康检查: + +```bash +curl http://127.0.0.1:8000/healthz +``` + +同步状态: + +```bash +curl http://127.0.0.1:8000/api/sync/status +``` + +## 11. 常见问题 + +### 控制台看不到画面回显 + +先强制刷新控制台页面。画面回显在控制台顶部,标题为“画面回显”。 + +如果仍然看不到,检查: + +```text +http://控制服务IP:8000/output/left?preview=1 +http://控制服务IP:8000/output/right?preview=1 +``` + +这两个地址应能单独打开预览画面。 + +### 命令回显一直显示等待 + +检查真实输出端是否打开的是: + +```text +/output/left +/output/right +``` + +不要把生产输出端打开成 `?preview=1`,预览模式不会 ACK。 + +### 切换场景不放行 + +说明至少一个真实输出端没有回 `prepared`。查看: + +```text +/api/sync/status +``` + +重点看 `nodes` 是否有 left/right,`commands[].acks` 是否有两个 tile。 + +### 局域网机器打不开 + +检查: + +- 服务是否监听 `0.0.0.0:8000`。 +- 控制服务机器防火墙是否允许 8000。 +- 输出服务器是否和控制服务在同一网络。 +- URL 是否使用控制服务的局域网 IP,而不是 `127.0.0.1`。 + +### 控制台预览和生产输出不同步 + +控制台预览是观察用途,运行在控制台浏览器中,不参与 ACK,也不代表生产输出端性能。最终验收应以真实 `/output/left` 和 `/output/right` 为准。 diff --git a/led_platform/api/http.py b/led_platform/api/http.py index 1c3c66e..81baaf1 100644 --- a/led_platform/api/http.py +++ b/led_platform/api/http.py @@ -13,6 +13,11 @@ STATIC_DIR = WEB_DIR / "static" router = APIRouter() +HTML_HEADERS = { + "Cache-Control": "no-store", + "Clear-Site-Data": '"cache"', +} + ALLOWED_ACTIONS = { "page.next", "page.prev", @@ -20,12 +25,14 @@ ALLOWED_ACTIONS = { "security.alert", "timeline.pause", "timeline.resume", + "motion.mode", + "scenario.focus", } @router.get("/", include_in_schema=False) async def admin_index() -> FileResponse: - return FileResponse(STATIC_DIR / "admin" / "index.html") + return FileResponse(STATIC_DIR / "admin" / "index.html", headers=HTML_HEADERS) @router.get("/output/{tile_id}", include_in_schema=False) @@ -34,12 +41,12 @@ async def output_index(tile_id: str) -> FileResponse: tiles.get(tile_id) except KeyError as exc: raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc - return FileResponse(STATIC_DIR / "output" / "index.html") + return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS) @router.get("/wall-app", include_in_schema=False) async def wall_app() -> FileResponse: - return FileResponse(STATIC_DIR / "output" / "index.html") + return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS) @router.get("/favicon.ico", include_in_schema=False) @@ -106,7 +113,6 @@ async def switch_scene(scene_id: str) -> dict: ) sync.register_command(commit, payload) await hub.broadcast(prepare, "admin", "outputs") - await hub.broadcast(commit, "admin", "outputs") return payload @@ -149,4 +155,3 @@ async def sync_status() -> dict: async def telemetry(sample: PerformanceSample) -> dict: sync.telemetry(sample) return {"ok": True} - diff --git a/led_platform/api/ws.py b/led_platform/api/ws.py index dd43aed..6ad5629 100644 --- a/led_platform/api/ws.py +++ b/led_platform/api/ws.py @@ -90,6 +90,19 @@ async def output_ws(websocket: WebSocket, tile_id: str) -> None: node_id = str(raw.get("node_id") or node_id or "") sync.clock_quality(node_id, raw.get("clock_offset_ms"), raw.get("rtt_ms")) record = sync.ack(raw) + release_record = None + if record and raw.get("status") == "prepared": + release_record = sync.release_if_prepared(record.command_id) + if release_record: + await hub.broadcast( + CommandEnvelope( + type=CommandType.COMMIT_SCENE, + command_id=release_record.command_id, + payload=release_record.payload, + ), + "admin", + "outputs", + ) await hub.broadcast( CommandEnvelope( type=CommandType.ACK, diff --git a/led_platform/domain/models.py b/led_platform/domain/models.py index 9f67c3e..66e9aa5 100644 --- a/led_platform/domain/models.py +++ b/led_platform/domain/models.py @@ -128,9 +128,19 @@ class CommandRecord(BaseModel): apply_at_ms: int created_at_ms: int = Field(default_factory=epoch_ms) expires_at_ms: int + released_at_ms: int | None = None payload: dict[str, Any] = Field(default_factory=dict) acks: list[dict[str, Any]] = Field(default_factory=list) + @property + def prepared(self) -> bool: + prepared_tiles = { + ack.get("tile_id") + for ack in self.acks + if ack.get("status") in {AckStatus.PREPARED, AckStatus.COMMITTED} + } + return set(self.target_tiles).issubset({str(tile) for tile in prepared_tiles if tile}) + @property def complete(self) -> bool: completed = { diff --git a/led_platform/main.py b/led_platform/main.py index 361cda6..e435233 100644 --- a/led_platform/main.py +++ b/led_platform/main.py @@ -1,18 +1,26 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from starlette.responses import Response from led_platform.api.http import STATIC_DIR, router as http_router from led_platform.api.ws import router as ws_router from led_platform.core.config import get_settings +class NoCacheStaticFiles(StaticFiles): + async def get_response(self, path: str, scope: dict) -> Response: + response = await super().get_response(path, scope) + response.headers["Cache-Control"] = "no-store" + return response + + def create_app() -> FastAPI: app = FastAPI( title="LED Wall Projection Platform", version="1.0.0", description="Tile-aware dual GPU-server LED wall control and synchronization platform.", ) - app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static") app.include_router(http_router) app.include_router(ws_router) return app diff --git a/led_platform/services/sync_coordinator.py b/led_platform/services/sync_coordinator.py index c8421f5..b2b9908 100644 --- a/led_platform/services/sync_coordinator.py +++ b/led_platform/services/sync_coordinator.py @@ -65,6 +65,15 @@ class SyncCoordinator: record.acks.append(ack_payload) return record + def release_if_prepared(self, command_id: str) -> CommandRecord | None: + with self._lock: + self._prune_locked() + record = self._commands.get(command_id) + if not record or record.released_at_ms is not None or not record.prepared: + return None + record.released_at_ms = epoch_ms() + return record + def clock_quality( self, node_id: str | None, diff --git a/led_platform/web/static/admin/index.html b/led_platform/web/static/admin/index.html index c60af09..fb8bf83 100644 --- a/led_platform/web/static/admin/index.html +++ b/led_platform/web/static/admin/index.html @@ -7,25 +7,95 @@ -
+

LED 大屏投放控制台

-

正在连接控制服务...

+

{{ statusText }}

- - WebSocket + + {{ wsText }}
-
+
+
+
+
+

画面回显

+

14880 x 3510 拼接预览,只读不 ACK

+
+ +
+
+
+
+ left + {{ previewLabels.left }} +
+ +
+
+
+ right + {{ previewLabels.right }} +
+ +
+
+
+

场景切换

- +
-
+
+
+
+

{{ scene.name }}

+

{{ scene.view }} | {{ scene.url }}

+

{{ scene.description }}

+
+ +
+
+
+ +
+
+

局部动作

+
+
+ +
+ +
@@ -38,30 +108,54 @@ 同步状态 API 文档 -
+
+
+ 输出节点 + {{ nodes.length }}/{{ targetTiles.length }} +
+
+ {{ node.tile_id }} {{ node.node_id.slice(0, 16) }} + {{ nodeMetric(node) }} +
+
-
-

局部动作

+
+
+

生产命令回显

+

真实输出端 ACK,不代表上方预览

+
-
- - - - - - - +
+
暂无命令回显
+
+
+
+

{{ commandTitle(command) }}

+

{{ command.command_id.slice(0, 12) }} | 计划 {{ formatClock(command.apply_at_ms) }} | {{ releaseText(command) }}

+

{{ commandNote(command) }}

+
+ {{ commandStateText(command) }} +
+
+
+ {{ tileId }} + {{ tileEchoLabel(command, tileId) }} + {{ tileEchoDetail(command, tileId) }} +
+
+
- -
+ diff --git a/led_platform/web/static/admin/main.js b/led_platform/web/static/admin/main.js index 2ff0963..1b052c7 100644 --- a/led_platform/web/static/admin/main.js +++ b/led_platform/web/static/admin/main.js @@ -1,155 +1,250 @@ -const sceneList = document.querySelector("#sceneList"); -const stateText = document.querySelector("#stateText"); -const wsDot = document.querySelector("#wsDot"); -const wsText = document.querySelector("#wsText"); -const refreshBtn = document.querySelector("#refreshBtn"); -const runActionBtn = document.querySelector("#runActionBtn"); -const actionInput = document.querySelector("#actionInput"); -const syncStatus = document.querySelector("#syncStatus"); +const { createApp } = Vue; -let scenes = []; -let state = null; -let syncSnapshot = null; +createApp({ + data() { + return { + scenes: [], + state: null, + syncSnapshot: null, + wsConnected: false, + wsText: "WebSocket", + statusText: "正在连接控制服务...", + busySceneId: null, + actionInput: '{"action":"security.alert","args":{"text":"消防通道占用"}}', + quickActions: [ + { label: "上一页", action: "page.prev" }, + { label: "下一页", action: "page.next" }, + { label: "削峰模式", action: "energy.mode", args: { mode: "削峰" } }, + { label: "保供模式", action: "energy.mode", args: { mode: "保供" } }, + { label: "轨迹流动", action: "motion.mode", args: { mode: "flow" } }, + { label: "脉冲扫描", action: "motion.mode", args: { mode: "pulse" } }, + { label: "节点巡航", action: "motion.mode", args: { mode: "orbit" } }, + { label: "新增告警", action: "security.alert", args: { text: "门禁异常联动" } }, + { label: "核心区域", action: "scenario.focus", args: { target: "core" } }, + { label: "边界区域", action: "scenario.focus", args: { target: "edge" } }, + { label: "暂停时间轴", action: "timeline.pause" }, + { label: "恢复时间轴", action: "timeline.resume" }, + ], + }; + }, -async function loadScenes() { - const res = await fetch("/api/scenes"); - const data = await res.json(); - scenes = data.scenes; - state = data.state; - renderScenes(); -} + computed: { + nodes() { + return this.syncSnapshot?.nodes || []; + }, + targetTiles() { + return this.syncSnapshot?.target_tiles || []; + }, + recentCommands() { + return [...(this.syncSnapshot?.commands || [])].slice(-6).reverse(); + }, + connectedTiles() { + return new Set(this.nodes.map((node) => node.tile_id)); + }, + previewLabels() { + return { + left: this.state ? `${this.state.active_scene_id} / v${this.state.version}` : "--", + right: this.state ? `${this.state.active_scene_id} / v${this.state.version}` : "--", + }; + }, + }, -async function loadSyncStatus() { - const res = await fetch("/api/sync/status"); - syncSnapshot = await res.json(); - renderSyncStatus(); -} + async mounted() { + await Promise.all([this.loadScenes(), this.loadSyncStatus()]); + this.connectWs(); + setInterval(() => this.loadSyncStatus(), 2500); + }, -function renderScenes() { - if (!state) return; - stateText.textContent = `当前场景:${state.active_scene_id},版本 ${state.version}`; - sceneList.innerHTML = ""; - for (const scene of scenes) { - const item = document.createElement("article"); - item.className = `scene ${scene.id === state.active_scene_id ? "active" : ""}`; - item.innerHTML = ` -
-

${scene.name}

-

${scene.view} | ${scene.url}

-

${scene.description ?? ""}

-
- - `; - item.querySelector("button").addEventListener("click", () => switchScene(scene.id)); - sceneList.append(item); - } -} + methods: { + async loadScenes() { + const res = await fetch("/api/scenes"); + const data = await res.json(); + this.scenes = data.scenes; + this.state = data.state; + this.statusText = `当前场景:${this.state.active_scene_id},版本 ${this.state.version}`; + }, -function renderSyncStatus() { - if (!syncSnapshot) return; - const nodes = syncSnapshot.nodes || []; - const commands = (syncSnapshot.commands || []).slice(-5).reverse(); - const rows = []; - rows.push(`
输出节点${nodes.length}/${syncSnapshot.target_tiles.length}
`); + async loadSyncStatus() { + const res = await fetch("/api/sync/status"); + this.syncSnapshot = await res.json(); + }, - for (const node of nodes) { - const fps = node.fps == null ? "--" : node.fps.toFixed(1); - const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`; - const rtt = node.rtt_ms == null ? "--" : `${Math.round(node.rtt_ms)}ms`; - const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`; - rows.push(`
${node.tile_id} ${node.node_id.slice(0, 16)}${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}
`); - } + async switchScene(sceneId) { + this.busySceneId = sceneId; + try { + const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" }); + if (!res.ok) { + alert(await res.text()); + return; + } + const data = await res.json(); + this.state = data.state; + this.statusText = `计划切换:${this.state.active_scene_id},提交时间 ${this.formatClock(data.apply_at_ms)}`; + await this.loadSyncStatus(); + } finally { + this.busySceneId = null; + } + }, - for (const command of commands) { - const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK"; - rows.push(`
${command.command_type} ${command.command_id.slice(0, 8)}${command.complete ? "完成" : "进行中"} | ${ackText}
`); - } + async postAction(action, args = {}, target = "wall") { + const res = await fetch("/api/actions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ target, action, args }), + }); + if (!res.ok) { + alert(await res.text()); + return; + } + await this.loadSyncStatus(); + }, - syncStatus.innerHTML = rows.join(""); -} + async runCustomAction() { + const raw = this.actionInput.trim(); + if (!raw) return; + let payload; + try { + payload = JSON.parse(raw); + } catch { + payload = { action: raw, args: {} }; + } + await this.postAction(payload.action, payload.args || {}, payload.target || "wall"); + }, -async function switchScene(sceneId) { - const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" }); - if (!res.ok) { - alert(await res.text()); - return; - } - const data = await res.json(); - state = data.state; - const time = new Date(data.apply_at_ms).toLocaleTimeString("zh-CN", { hour12: false }); - stateText.textContent = `计划切换:${state.active_scene_id},提交时间 ${time}`; - renderScenes(); - loadSyncStatus(); -} + connectWs() { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`); + ws.addEventListener("open", () => { + this.wsConnected = true; + this.wsText = "WebSocket 已连接"; + }); + ws.addEventListener("close", () => { + this.wsConnected = false; + this.wsText = "WebSocket 重连中"; + setTimeout(() => this.connectWs(), 1200); + }); + ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.type === "state") { + this.state = message.payload.state || message.payload; + this.statusText = `当前场景:${this.state.active_scene_id},版本 ${this.state.version}`; + } + if (message.type === "prepare_scene") { + this.state = message.payload.state; + this.statusText = `准备切换:${this.state.active_scene_id},等待输出端渲染,提交时间 ${this.formatClock(message.payload.apply_at_ms)}`; + void this.loadSyncStatus(); + } + if (message.type === "commit_scene") { + this.state = message.payload.state; + this.statusText = `已放行:${this.state.active_scene_id},提交时间 ${this.formatClock(message.payload.apply_at_ms)}`; + void this.loadSyncStatus(); + } + if (message.type === "ack") { + this.wsText = `${message.payload.tile_id || "node"} ${message.payload.status}`; + void this.loadSyncStatus(); + } + if (message.type === "heartbeat") { + void this.loadSyncStatus(); + } + }); + }, -async function postAction(action, args = {}, target = "wall") { - const res = await fetch("/api/actions", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ target, action, args }), - }); - if (!res.ok) { - alert(await res.text()); - return; - } - loadSyncStatus(); -} + formatClock(ms) { + if (!Number.isFinite(Number(ms))) return "--"; + return new Date(Number(ms)).toLocaleTimeString("zh-CN", { hour12: false }); + }, -async function runCustomAction() { - const raw = actionInput.value.trim(); - if (!raw) return; - let payload; - try { - payload = JSON.parse(raw); - } catch { - payload = { action: raw, args: {} }; - } - await postAction(payload.action, payload.args || {}, payload.target || "wall"); -} + formatDelta(ms) { + if (!Number.isFinite(Number(ms))) return "--"; + const value = Math.round(Number(ms)); + return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`; + }, -function connectWs() { - const protocol = location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`); - ws.addEventListener("open", () => { - wsDot.classList.add("ok"); - wsText.textContent = "WebSocket 已连接"; - }); - ws.addEventListener("close", () => { - wsDot.classList.remove("ok"); - wsText.textContent = "WebSocket 重连中"; - setTimeout(connectWs, 1200); - }); - ws.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "state") { - state = message.payload.state || message.payload; - renderScenes(); - } - if (["prepare_scene", "commit_scene"].includes(message.type)) { - state = message.payload.state; - renderScenes(); - loadSyncStatus(); - } - if (message.type === "ack") { - wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`; - loadSyncStatus(); - } - if (message.type === "heartbeat") { - loadSyncStatus(); - } - }); -} + nodeMetric(node) { + const fps = node.fps == null ? "--" : node.fps.toFixed(1); + const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`; + const rtt = node.rtt_ms == null ? "--" : `${Math.round(node.rtt_ms)}ms`; + const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`; + return `${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}`; + }, -refreshBtn.addEventListener("click", loadScenes); -runActionBtn.addEventListener("click", runCustomAction); -document.querySelectorAll("[data-action]").forEach((button) => { - button.addEventListener("click", () => { - const args = button.dataset.args ? JSON.parse(button.dataset.args) : {}; - postAction(button.dataset.action, args); - }); -}); + commandTitle(command) { + if (command.command_type === "commit_scene") { + return `场景 ${command.payload?.state?.active_scene_id || command.command_id.slice(0, 8)}`; + } + if (command.command_type === "component_action") { + return `动作 ${command.payload?.action || command.command_id.slice(0, 8)}`; + } + return `${command.command_type} ${command.command_id.slice(0, 8)}`; + }, -loadScenes(); -loadSyncStatus(); -connectWs(); -setInterval(loadSyncStatus, 2500); + releaseText(command) { + return command.released_at_ms + ? `放行 ${this.formatDelta(command.released_at_ms - command.created_at_ms)}` + : "等待真实输出端渲染"; + }, + + missingTiles(command) { + return this.targetTiles.filter((tileId) => !this.connectedTiles.has(tileId)); + }, + + commandStateText(command) { + if (command.complete) return "生产完成"; + if (command.released_at_ms) return "已放行"; + if (this.missingTiles(command).length) return "输出未连接"; + return "等待 ACK"; + }, + + commandStateClass(command) { + if (command.complete) return "ok"; + if (command.released_at_ms) return "ready"; + return "wait"; + }, + + commandNote(command) { + const missing = this.missingTiles(command); + if (missing.length) { + return `预览已按计划更新;生产同步等待 ${missing.join(", ")} 输出端连接`; + } + return "预览已按计划更新;生产同步等待真实输出端 ACK"; + }, + + latestAckForTile(command, tileId) { + return [...(command.acks || [])].reverse().find((ack) => ack.tile_id === tileId); + }, + + tileEchoStatus(command, tileId) { + if (!this.connectedTiles.has(tileId)) return "disconnected"; + return this.latestAckForTile(command, tileId)?.status || "waiting"; + }, + + tileEchoClass(command, tileId) { + const status = this.tileEchoStatus(command, tileId); + if (["committed", "action_committed"].includes(status)) return "ok"; + if (status === "prepared") return "ready"; + if (["late", "error"].includes(status)) return "bad"; + return "wait"; + }, + + tileEchoLabel(command, tileId) { + const status = this.tileEchoStatus(command, tileId); + return { + disconnected: "未连接", + waiting: "等待", + prepared: "已渲染", + committed: "已输出", + action_committed: "已执行", + late: "迟到", + error: "错误", + }[status] || status; + }, + + tileEchoDetail(command, tileId) { + if (!this.connectedTiles.has(tileId)) return `打开 /output/${tileId} 才会 ACK`; + const ack = this.latestAckForTile(command, tileId); + if (!ack) return "等待真实输出端 ACK"; + const rtt = ack.rtt_ms == null ? "--" : `${Math.round(ack.rtt_ms)}ms`; + return `${this.formatClock(ack.server_received_ms)} / RTT ${rtt}`; + }, + }, +}).mount("#adminApp"); diff --git a/led_platform/web/static/admin/styles.css b/led_platform/web/static/admin/styles.css index 8ed0367..b855d33 100644 --- a/led_platform/web/static/admin/styles.css +++ b/led_platform/web/static/admin/styles.css @@ -9,6 +9,10 @@ box-sizing: border-box; } +[v-cloak] { + display: none; +} + body { margin: 0; min-height: 100vh; @@ -24,9 +28,9 @@ textarea { } .shell { - width: min(1280px, calc(100vw - 40px)); + width: min(1760px, calc(100vw - 24px)); margin: 0 auto; - padding: 28px 0 40px; + padding: 16px 0 28px; } .topbar, @@ -38,8 +42,12 @@ textarea { gap: 16px; } +.panelHead.compact { + margin-bottom: 10px; +} + .topbar { - margin-bottom: 22px; + margin-bottom: 14px; } h1, @@ -50,7 +58,7 @@ p { } h1 { - font-size: 28px; + font-size: 24px; letter-spacing: 0; } @@ -78,27 +86,42 @@ h1 { box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16); } -.grid { +.consoleLayout { display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.8fr); - gap: 18px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.previewPanel { + grid-column: 1 / -1; } .panel { border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; background: rgba(255, 255, 255, 0.055); - padding: 18px; + padding: 14px; } -.scenes { - grid-row: span 2; +.panelMeta { + color: #94a3b8; + font-size: 12px; +} + +.panelHint { + margin-top: 4px; + color: #94a3b8; + font-size: 12px; } h2 { font-size: 17px; } +h3 { + font-size: 15px; +} + button, .linkGrid a { border: 1px solid rgba(255, 255, 255, 0.14); @@ -115,6 +138,58 @@ button:hover, border-color: rgba(20, 184, 166, 0.8); } +button:disabled { + cursor: wait; + opacity: 0.62; +} + +.previewGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + margin-top: 10px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: #05070a; +} + +.previewTile { + overflow: hidden; + border: 0; + border-radius: 0; + background: #05070a; +} + +.previewTile header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.045); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.previewTile strong { + font-size: 13px; +} + +.previewTile a { + color: #5eead4; + font-size: 12px; + text-decoration: none; +} + +.previewTile iframe { + display: block; + width: 100%; + aspect-ratio: 7440 / 3510; + border: 0; + background: #05070a; +} + .primary { width: 100%; margin-top: 12px; @@ -127,7 +202,8 @@ button:hover, .sceneList, .syncStatus, .linkGrid, -.actionGrid { +.actionGrid, +.commandEcho { display: grid; gap: 10px; } @@ -135,19 +211,19 @@ button:hover, .sceneList, .syncStatus, .linkGrid { - margin-top: 14px; + margin-top: 10px; } .linkGrid { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } .scene { display: grid; grid-template-columns: 1fr auto; - gap: 12px; + gap: 10px; align-items: center; - padding: 14px; + padding: 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.045); @@ -159,13 +235,13 @@ button:hover, } .scene h3 { - font-size: 16px; - margin-bottom: 6px; + font-size: 14px; + margin-bottom: 4px; } .scene p { color: #aeb7c2; - font-size: 13px; + font-size: 12px; overflow-wrap: anywhere; } @@ -181,15 +257,105 @@ button:hover, color: #f8fafc; } +.echoCard { + display: grid; + gap: 12px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); +} + +.echoHead, +.tileEcho { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.echoHead h4 { + margin: 0 0 4px; + font-size: 14px; +} + +.echoHead p, +.tileEcho small, +.emptyEcho { + margin: 0; + color: #94a3b8; + font-size: 12px; +} + +.echoState, +.tileEcho strong { + flex: 0 0 auto; + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + font-weight: 760; +} + +.echoState.ok, +.tileEcho.ok strong { + background: rgba(34, 197, 94, 0.16); + color: #86efac; +} + +.echoState.ready, +.tileEcho.ready strong { + background: rgba(20, 184, 166, 0.16); + color: #5eead4; +} + +.echoState.wait, +.tileEcho.wait strong { + background: rgba(148, 163, 184, 0.14); + color: #cbd5e1; +} + +.tileEcho.bad strong { + background: rgba(239, 68, 68, 0.16); + color: #fca5a5; +} + +.tileEchoGrid { + display: grid; + gap: 8px; +} + +.tileEcho { + min-height: 48px; + padding: 8px 9px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); +} + +.tileEcho span { + min-width: 44px; + color: #f8fafc; + font-weight: 700; +} + +.tileEcho small { + text-align: right; +} + +.emptyEcho { + padding: 12px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.22); +} + .actionGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-top: 14px; + margin-top: 10px; } .customAction { display: grid; gap: 8px; - margin-top: 14px; + margin-top: 10px; color: #cbd5e1; } @@ -203,14 +369,44 @@ textarea { padding: 10px; } +@media (max-width: 1240px) { + .consoleLayout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .previewPanel { + grid-column: 1 / -1; + } +} + @media (max-width: 980px) { - .grid, + .shell { + width: min(100vw - 12px, 1760px); + padding-top: 8px; + } + + .panel { + padding: 10px; + } + + .consoleLayout, .topbar { grid-template-columns: 1fr; display: grid; } - .scenes { - grid-row: auto; + .previewTile header { + min-height: 34px; + padding: 6px 8px; + } + + .linkGrid { + grid-template-columns: 1fr; + } +} + +@media (orientation: portrait) and (max-width: 680px) { + .previewGrid { + grid-template-columns: 1fr; } } diff --git a/led_platform/web/static/output/index.html b/led_platform/web/static/output/index.html index beddfc9..5411eb7 100644 --- a/led_platform/web/static/output/index.html +++ b/led_platform/web/static/output/index.html @@ -7,61 +7,78 @@ -
-
- +
+
+
+
-

Tile-aware timeline rendering

-

LED 大屏投放平台

+

Synchronized LED Wall Platform

+

{{ sceneTitle }}

- --:--:-- - v0 + {{ clockText }} + v{{ state?.version || 0 }} + {{ tile?.id || "--" }} / {{ isPreview ? "PREVIEW" : "OUTPUT" }}
-
+
-

双 GPU 本地渲染,统一时间轴同步

-

左右输出端各自渲染半屏,但场景、动画、地图相机、视频时间由同一个服务端时间轴驱动。

+ {{ scene.kicker }} +

{{ scene.headline }}

+

{{ scene.copy }}

-
-
-
-
-
-

能源驾驶舱

-

适合 WebGL、Three.js、地图和视频组件。生产接入时使用同一 camera state 和 serverTime 渲染。

+
+
+ {{ metric.label }} + {{ metric.value }} + {{ metric.trend }} +
-
-
-
-
园区供电
-
储能系统
-
业务负载
-
-
-
实时功率8.42 MW
-
负载趋势+3.8%
-
调度策略均衡
-
-
-
-
-

安防态势

-

视频墙、地图联动和告警卡片通过结构化动作同步触发,左右节点只按计划时间提交状态。

+
+
+ +
+
+ {{ node.label }} + {{ node.value }} +
-
-
+ +
- +
+ diff --git a/led_platform/web/static/output/main.js b/led_platform/web/static/output/main.js index 2845246..91f884d 100644 --- a/led_platform/web/static/output/main.js +++ b/led_platform/web/static/output/main.js @@ -1,332 +1,583 @@ -const APP_VERSION = "3.0.0"; +const { createApp } = Vue; -const wall = document.querySelector("#wall"); -const canvas = document.querySelector("#motionCanvas"); -const ctx = canvas.getContext("2d", { alpha: true }); -const hud = document.querySelector("#hud"); -const sceneTitle = document.querySelector("#sceneTitle"); -const versionText = document.querySelector("#versionText"); -const clockText = document.querySelector("#clockText"); +const APP_VERSION = "4.0.0-vue3"; const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left"; +const isPreview = new URLSearchParams(location.search).get("preview") === "1"; +const nodeStorageKey = `led-platform-node-id-${tileId}`; -const sceneTitles = { - overview: "LED 大屏投放平台", - energy: "能源驾驶舱", - security: "安防态势", -}; - -let tile = null; -let state = null; -let ws = null; -let nodeId = sessionStorage.getItem("led-platform-node-id"); -let serverOffsetFromPerfMs = Date.now() - performance.now(); -let bestRttMs = Number.POSITIVE_INFINITY; -let clockSamples = []; -let scheduledJobs = new Map(); -let frameCount = 0; -let fps = 0; -let frameTimeMs = 0; -let droppedFrames = 0; -let lastFrameAt = performance.now(); -let lastFpsAt = performance.now(); -let pageIndex = 0; -let paused = false; - -if (!nodeId) { - const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2); - nodeId = `${tileId}-${randomId}`; - sessionStorage.setItem("led-platform-node-id", nodeId); +function createSceneDefinitions() { + return [ + { + id: "overview", + title: "LED 大屏投放平台", + kicker: "GLOBAL WALL OVERVIEW", + headline: "双 GPU 本地渲染,统一时间轴同步", + copy: "左右输出端各自渲染半屏,场景、动作、地图相机和动画由同一个服务端时间轴驱动。", + metrics: [ + { label: "逻辑宽度", value: "14880", trend: "px" }, + { label: "逻辑高度", value: "3510", trend: "px" }, + { label: "输出节点", value: "2", trend: "GPU servers" }, + { label: "帧同步", value: "ACK", trend: "barrier" }, + ], + lines: ["line-a", "line-b", "line-c"], + nodes: [ + { id: "control", label: "控制服务", value: "ACTIVE", x: 720, y: 620, tone: "cyan" }, + { id: "left", label: "左侧 tile", value: "7440px", x: 3400, y: 1100, tone: "blue" }, + { id: "right", label: "右侧 tile", value: "7440px", x: 7100, y: 880, tone: "orange" }, + { id: "wall", label: "LED 控制器", value: "8 INPUT", x: 10300, y: 1260, tone: "cyan" }, + ], + events: [ + { time: "T+00", title: "场景统一", detail: "prepare / commit 协议" }, + { time: "T+02", title: "时间校准", detail: "RTT 采样与 serverNow" }, + { time: "T+04", title: "本地渲染", detail: "两端分别绘制 tile" }, + ], + }, + { + id: "energy", + title: "能源驾驶舱", + kicker: "ENERGY GRID COMMAND", + headline: "源网荷储一体化调度", + copy: "实时监控园区供电、储能、负荷趋势和削峰策略,支持模式切换与调度策略下发。", + metrics: [ + { label: "实时功率", value: "8.42", trend: "MW" }, + { label: "储能 SOC", value: "72", trend: "%" }, + { label: "负载趋势", value: "+3.8", trend: "%" }, + { label: "调度策略", value: "均衡", trend: "auto" }, + ], + lines: ["line-a", "line-d", "line-e"], + nodes: [ + { id: "grid", label: "园区供电", value: "稳定", x: 850, y: 760, tone: "cyan" }, + { id: "storage", label: "储能系统", value: "72%", x: 3900, y: 520, tone: "blue" }, + { id: "load", label: "业务负载", value: "8.42MW", x: 7600, y: 980, tone: "orange" }, + { id: "strategy", label: "调度策略", value: "均衡", x: 10800, y: 620, tone: "cyan" }, + ], + events: [ + { time: "09:12", title: "负载预测上调", detail: "未来 15 分钟 +3.8%" }, + { time: "09:18", title: "储能响应", detail: "2 组电池进入待命" }, + { time: "09:23", title: "策略校验", detail: "削峰阈值未触发" }, + ], + }, + { + id: "security", + title: "安防态势", + kicker: "SECURITY SITUATION", + headline: "视频墙、告警与区域联动", + copy: "多路视频、门禁、消防与巡检事件按统一时间轴联动,支持局部告警动作同步触发。", + metrics: [ + { label: "在线摄像", value: "128", trend: "channels" }, + { label: "风险指数", value: "17", trend: "low" }, + { label: "巡检任务", value: "42", trend: "running" }, + { label: "联动告警", value: "4", trend: "active" }, + ], + lines: ["line-b", "line-c", "line-f"], + nodes: [ + { id: "north", label: "北区通道", value: "关注", x: 980, y: 880, tone: "orange" }, + { id: "center", label: "中心机房", value: "正常", x: 4200, y: 530, tone: "cyan" }, + { id: "gate", label: "门禁系统", value: "1 异常", x: 7550, y: 1200, tone: "orange" }, + { id: "fire", label: "消防联动", value: "待命", x: 10900, y: 760, tone: "blue" }, + ], + events: [ + { time: "10:31", title: "门禁异常联动", detail: "B2 机房侧门" }, + { time: "10:34", title: "人员聚集", detail: "北侧通道" }, + { time: "10:40", title: "巡检完成", detail: "A 区 12 个点位" }, + ], + }, + { + id: "command", + title: "指挥调度", + kicker: "MISSION CONTROL", + headline: "跨区域任务派发与执行闭环", + copy: "事件、队伍、资源和任务状态聚合在同一个指挥视图,适合现场大屏调度与演练展示。", + metrics: [ + { label: "事件队列", value: "23", trend: "items" }, + { label: "响应队伍", value: "11", trend: "teams" }, + { label: "闭环率", value: "96", trend: "%" }, + { label: "平均响应", value: "4.6", trend: "min" }, + ], + lines: ["line-a", "line-e", "line-f"], + nodes: [ + { id: "hq", label: "指挥中心", value: "ONLINE", x: 920, y: 620, tone: "cyan" }, + { id: "team", label: "响应队伍", value: "11", x: 4100, y: 1080, tone: "blue" }, + { id: "resource", label: "资源池", value: "82%", x: 7500, y: 620, tone: "orange" }, + { id: "task", label: "任务闭环", value: "96%", x: 10800, y: 1050, tone: "cyan" }, + ], + events: [ + { time: "11:05", title: "任务派发", detail: "3 支队伍已接收" }, + { time: "11:08", title: "资源锁定", detail: "移动电源与无人机" }, + { time: "11:16", title: "阶段回执", detail: "现场反馈正常" }, + ], + }, + { + id: "transport", + title: "交通运行", + kicker: "TRANSPORT NETWORK", + headline: "路网运行与运力调度", + copy: "车流、站点、路段拥堵与运力资源在大屏上联动呈现,支持重点区域快速聚焦。", + metrics: [ + { label: "车流指数", value: "68", trend: "stable" }, + { label: "拥堵路段", value: "7", trend: "segments" }, + { label: "准点率", value: "91", trend: "%" }, + { label: "可用运力", value: "84", trend: "%" }, + ], + lines: ["line-b", "line-d", "line-f"], + nodes: [ + { id: "hub", label: "枢纽站", value: "91%", x: 760, y: 980, tone: "cyan" }, + { id: "road", label: "主干路", value: "68", x: 3900, y: 540, tone: "orange" }, + { id: "fleet", label: "运力池", value: "84%", x: 7600, y: 1150, tone: "blue" }, + { id: "signal", label: "信号优化", value: "运行", x: 10900, y: 700, tone: "cyan" }, + ], + events: [ + { time: "12:20", title: "车流高峰", detail: "东向主线压力上升" }, + { time: "12:26", title: "信号配时", detail: "绿波方案已下发" }, + { time: "12:31", title: "运力补偿", detail: "3 条线路加车" }, + ], + }, + { + id: "dataflow", + title: "数据中枢", + kicker: "AI DATA FABRIC", + headline: "数据流入、计算与智能分析", + copy: "多源数据经过实时管道进入计算集群,AI 推理结果以指标、事件和预测态势输出。", + metrics: [ + { label: "接入数据", value: "2.8", trend: "TB/h" }, + { label: "计算任务", value: "418", trend: "jobs" }, + { label: "AI 推理", value: "36", trend: "models" }, + { label: "延迟 P95", value: "42", trend: "ms" }, + ], + lines: ["line-a", "line-c", "line-d"], + nodes: [ + { id: "ingest", label: "数据接入", value: "2.8TB/h", x: 780, y: 650, tone: "cyan" }, + { id: "compute", label: "计算集群", value: "418", x: 4100, y: 1050, tone: "blue" }, + { id: "ai", label: "AI 推理", value: "36", x: 7700, y: 580, tone: "orange" }, + { id: "output", label: "指标输出", value: "42ms", x: 10800, y: 1160, tone: "cyan" }, + ], + events: [ + { time: "13:02", title: "数据流入峰值", detail: "北区视频数据增加" }, + { time: "13:07", title: "模型推理完成", detail: "风险识别批次 19" }, + { time: "13:10", title: "指标写入", detail: "P95 延迟 42ms" }, + ], + }, + ]; } -function serverNowMs() { - return performance.now() + serverOffsetFromPerfMs; -} +createApp({ + data() { + return { + tile: null, + state: null, + activeView: "overview", + sceneDefinitions: createSceneDefinitions(), + ws: null, + nodeId: sessionStorage.getItem(nodeStorageKey), + serverOffsetFromDateMs: 0, + bestRttMs: Number.POSITIVE_INFINITY, + clockSamples: [], + scheduledJobs: new Map(), + frameCount: 0, + fps: 0, + frameTimeMs: 0, + droppedFrames: 0, + lastFrameAt: performance.now(), + lastFpsAt: performance.now(), + pageIndex: 0, + paused: false, + motionMode: "flow", + focusTarget: "core", + pulseActive: false, + clockText: "--:--:--", + hudText: "", + ctx: null, + isPreview, + }; + }, -function clientNowMs() { - return Date.now(); -} + computed: { + sceneTitle() { + return this.sceneDefinitions.find((scene) => scene.id === this.activeView)?.title || "LED 大屏投放平台"; + }, + }, -async function bootstrap() { - const response = await fetch(`/api/bootstrap/${tileId}`); - const data = await response.json(); - tile = data.tile; - applyState(data.state); - buildStaticContent(); - layout(); - connectWs(); - requestAnimationFrame(renderLoop); -} + async mounted() { + if (!this.nodeId) { + const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2); + this.nodeId = `${tileId}-${randomId}`; + sessionStorage.setItem(nodeStorageKey, this.nodeId); + } + this.ctx = this.$refs.motionCanvas.getContext("2d", { alpha: true }); + await this.bootstrap(); + this.tickClock(); + setInterval(() => this.tickClock(), 1000); + setInterval(() => this.syncClock(), 2000); + if (!isPreview) setInterval(() => this.sendTelemetry(), 1000); + requestAnimationFrame((now) => this.renderLoop(now)); + }, -function buildStaticContent() { - document.querySelector("#overviewCards").innerHTML = [ - ["同步模式", "时间轴"], - ["渲染节点", "双 GPU"], - ["逻辑宽度", "14880"], - ["逻辑高度", "3510"], - ["半屏宽度", "7440"], - ["目标帧率", "60"], - ].map(([label, value]) => `
${label}${value}
`).join(""); + methods: { + async bootstrap() { + const response = await fetch(`/api/bootstrap/${tileId}`); + const data = await response.json(); + this.tile = data.tile; + this.applyState(data.state); + this.layout(); + if (isPreview) { + this.connectPreviewWs(); + } else { + this.connectWs(); + } + window.addEventListener("resize", () => this.layout()); + window.addEventListener("keydown", (event) => { + if (event.key.toLowerCase() === "h") this.$refs.hud.hidden = !this.$refs.hud.hidden; + }); + }, - const bars = document.querySelector("#timelineBars"); - bars.innerHTML = ""; - for (let index = 0; index < 32; index += 1) { - const bar = document.createElement("i"); - bar.style.height = `${180 + ((index * 137) % 760)}px`; - bars.append(bar); - } + layout() { + if (!this.tile) return; + const scale = Math.min(window.innerWidth / this.tile.width, window.innerHeight / this.tile.height); + const tileRenderWidth = this.tile.width * scale; + const tileRenderHeight = this.tile.height * scale; + const root = document.documentElement; + root.style.setProperty("--wall-width", `${this.tile.wall_width}px`); + root.style.setProperty("--wall-height", `${this.tile.wall_height}px`); + root.style.setProperty("--tile-render-width", `${tileRenderWidth}px`); + root.style.setProperty("--tile-render-height", `${tileRenderHeight}px`); + root.style.setProperty("--scale", `${scale}`); + root.style.setProperty("--offset-x", `${-this.tile.x * scale}px`); + root.style.setProperty("--offset-y", `${-this.tile.y * scale}px`); + const appEl = document.querySelector("#outputApp") || (this.$el?.style ? this.$el : null); + const canvas = document.querySelector("#motionCanvas") || this.$refs.motionCanvas; + if (appEl) { + appEl.style.width = `${tileRenderWidth}px`; + appEl.style.height = `${tileRenderHeight}px`; + } + if (canvas) { + canvas.width = this.tile.wall_width; + canvas.height = this.tile.wall_height; + if (!this.ctx) this.ctx = canvas.getContext("2d", { alpha: true }); + } + }, - document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => { - const id = String(index + 1).padStart(2, "0"); - return `
CAM-${id}在线 | 1080P | 低延迟
`; - }).join(""); + applyState(nextState) { + this.state = nextState; + this.activeView = nextState.active_view || "overview"; + this.pulse(); + }, - document.querySelector("#alertRail").innerHTML = [ - "北侧通道人员聚集", - "机房门禁异常", - "消防通道占用", - "视频质量波动", - ].map((text) => `
${text}
`).join(""); -} + scheduleJob(message, kind, callback) { + const applyAtMs = Number(message.payload.apply_at_ms); + this.scheduledJobs.set(message.command_id, { + id: message.command_id, + kind, + applyAtMs, + message, + callback, + }); + }, -function layout() { - if (!tile) return; - const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height); - document.documentElement.style.setProperty("--wall-width", `${tile.wall_width}px`); - document.documentElement.style.setProperty("--wall-height", `${tile.wall_height}px`); - document.documentElement.style.setProperty("--scale", `${scale}`); - document.documentElement.style.setProperty("--offset-x", `${-tile.x * scale}px`); - document.documentElement.style.setProperty("--offset-y", `${-tile.y * scale}px`); - canvas.width = tile.wall_width; - canvas.height = tile.wall_height; -} + drainScheduledJobs() { + if (!this.scheduledJobs.size) return; + const now = this.serverNowMs(); + const ready = Array.from(this.scheduledJobs.values()) + .filter((job) => now >= job.applyAtMs) + .sort((a, b) => a.applyAtMs - b.applyAtMs); + for (const job of ready) { + this.scheduledJobs.delete(job.id); + const late = now - job.applyAtMs > 50; + job.callback(late); + } + }, -function applyState(nextState) { - state = nextState; - const view = state.active_view || "overview"; - document.querySelectorAll(".scene").forEach((scene) => { - scene.classList.toggle("active", scene.dataset.view === view); - }); - sceneTitle.textContent = sceneTitles[view] || sceneTitles.overview; - versionText.textContent = `v${state.version || 0}`; - pulse(); -} + async prepareScene(message) { + const startedAtMs = this.serverNowMs(); + try { + await this.prepareSceneFrame(message); + this.sendAck(message, "prepared", { + prepare_duration_ms: Math.max(0, this.serverNowMs() - startedAtMs), + }); + } catch (error) { + this.sendAck(message, "error", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, -function scheduleJob(message, kind, callback) { - const applyAtMs = Number(message.payload.apply_at_ms); - scheduledJobs.set(message.command_id, { - id: message.command_id, - kind, - applyAtMs, - message, - callback, - preparedAtMs: serverNowMs(), - }); -} + async prepareSceneFrame(message) { + if (typeof window.ledPlatformPrepareScene === "function") { + await window.ledPlatformPrepareScene({ + commandId: message.command_id, + tile: this.tile, + state: message.payload.state, + scene: message.payload.scene, + }); + return; + } + await Promise.all((message.payload.scene?.preload || []).map((url) => this.preloadUrl(url))); + await this.waitForFrames(2); + }, -function drainScheduledJobs() { - if (!scheduledJobs.size) return; - const now = serverNowMs(); - const ready = Array.from(scheduledJobs.values()) - .filter((job) => now >= job.applyAtMs) - .sort((a, b) => a.applyAtMs - b.applyAtMs); - for (const job of ready) { - scheduledJobs.delete(job.id); - const late = now - job.applyAtMs > 50; - job.callback(late); - } -} + preloadUrl(url) { + return new Promise((resolve) => { + const image = new Image(); + image.onload = resolve; + image.onerror = resolve; + image.src = url; + }); + }, -function prepareScene(message) { - sendAck(message, "prepared"); -} + waitForFrames(count = 2) { + return new Promise((resolve) => { + const step = (remaining) => { + if (remaining <= 0) { + resolve(); + return; + } + requestAnimationFrame(() => step(remaining - 1)); + }; + step(count); + }); + }, -function commitScene(message) { - scheduleJob(message, "scene", (late) => { - applyState(message.payload.state); - sendAck(message, late ? "late" : "committed"); - }); -} + commitScene(message) { + this.scheduleJob(message, "scene", (late) => { + this.applyState(message.payload.state); + this.sendAck(message, late ? "late" : "committed"); + }); + }, -function runComponentAction(message) { - scheduleJob(message, "action", (late) => { - applyAction(message.payload.action, message.payload.args || {}); - sendAck(message, late ? "late" : "action_committed"); - }); -} + commitScenePreview(message) { + this.scheduleJob(message, "scene-preview", () => { + this.applyState(message.payload.state); + }); + }, -function applyAction(action, args) { - if (action === "page.next") { - pageIndex += 1; - const views = ["overview", "energy", "security"]; - applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 }); - } else if (action === "page.prev") { - pageIndex = Math.max(0, pageIndex - 1); - const views = ["overview", "energy", "security"]; - applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 }); - } else if (action === "energy.mode") { - document.querySelector("#modeValue").textContent = args.mode || "削峰"; - } else if (action === "security.alert") { - const rail = document.querySelector("#alertRail"); - const alert = document.createElement("article"); - alert.className = "alert-card pulse"; - alert.textContent = args.text || "新增联动告警"; - rail.prepend(alert); - while (rail.children.length > 5) rail.lastElementChild.remove(); - } else if (action === "timeline.pause") { - paused = true; - } else if (action === "timeline.resume") { - paused = false; - } - pulse(); -} + runComponentAction(message) { + this.scheduleJob(message, "action", (late) => { + this.applyAction(message.payload.action, message.payload.args || {}); + this.sendAck(message, late ? "late" : "action_committed"); + }); + }, -function timelineMs() { - if (!state) return 0; - return Math.max(0, serverNowMs() - state.scene_started_at_ms); -} + runComponentActionPreview(message) { + this.scheduleJob(message, "action-preview", () => { + this.applyAction(message.payload.action, message.payload.args || {}); + }); + }, -function renderLoop(now) { - frameTimeMs = now - lastFrameAt; - if (frameTimeMs > 40) droppedFrames += 1; - frameCount += 1; - if (now - lastFpsAt >= 1000) { - fps = frameCount * 1000 / (now - lastFpsAt); - frameCount = 0; - lastFpsAt = now; - } - lastFrameAt = now; + applyAction(action, args) { + if (action === "page.next" || action === "page.prev") { + const views = this.sceneDefinitions.map((scene) => scene.id); + const direction = action === "page.next" ? 1 : -1; + this.pageIndex = (views.indexOf(this.activeView) + direction + views.length) % views.length; + this.applyState({ + ...this.state, + active_view: views[this.pageIndex], + version: (this.state?.version || 0) + 1, + }); + } else if (action === "energy.mode") { + this.updateMetric("energy", "调度策略", args.mode || "削峰", "manual"); + } else if (action === "security.alert") { + this.prependEvent("security", { + time: this.clockText, + title: args.text || "新增联动告警", + detail: "结构化动作同步触发", + }); + } else if (action === "motion.mode") { + this.motionMode = args.mode || "flow"; + } else if (action === "scenario.focus") { + this.focusTarget = args.target || "core"; + } else if (action === "timeline.pause") { + this.paused = true; + } else if (action === "timeline.resume") { + this.paused = false; + } + this.pulse(); + }, - drainScheduledJobs(); - drawMotion(paused ? 0 : timelineMs()); - animateBars(paused ? 0 : timelineMs()); - updateHud(); - requestAnimationFrame(renderLoop); -} + updateMetric(sceneId, label, value, trend) { + const scene = this.sceneDefinitions.find((item) => item.id === sceneId); + const metric = scene?.metrics.find((item) => item.label === label); + if (!metric) return; + metric.value = value; + metric.trend = trend; + }, -function drawMotion(t) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.globalAlpha = 0.75; - for (let i = 0; i < 28; i += 1) { - const phase = t / 1200 + i * 0.43; - const x = (Math.sin(phase * 0.46) * 0.5 + 0.5) * canvas.width; - const y = (Math.cos(phase * 0.37) * 0.5 + 0.5) * canvas.height; - const radius = 260 + (i % 6) * 70; - const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.4); - gradient.addColorStop(0, i % 2 ? "rgba(249,115,22,0.18)" : "rgba(20,184,166,0.22)"); - gradient.addColorStop(1, "rgba(0,0,0,0)"); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(x, y, radius * 3.4, 0, Math.PI * 2); - ctx.fill(); - } - ctx.globalAlpha = 1; -} + prependEvent(sceneId, event) { + const scene = this.sceneDefinitions.find((item) => item.id === sceneId); + if (!scene) return; + scene.events.unshift(event); + scene.events = scene.events.slice(0, 5); + }, -function animateBars(t) { - document.querySelectorAll("#timelineBars i").forEach((bar, index) => { - const scale = 0.82 + (Math.sin(t / 720 + index * 0.38) + 1) * 0.16; - bar.style.transform = `scaleY(${scale.toFixed(3)})`; - }); -} + timelineMs() { + if (!this.state) return 0; + return Math.max(0, this.serverNowMs() - this.state.scene_started_at_ms); + }, -function pulse() { - wall.classList.remove("pulse"); - requestAnimationFrame(() => wall.classList.add("pulse")); -} + renderLoop(now) { + this.frameTimeMs = now - this.lastFrameAt; + if (this.frameTimeMs > 40) this.droppedFrames += 1; + this.frameCount += 1; + if (now - this.lastFpsAt >= 1000) { + this.fps = this.frameCount * 1000 / (now - this.lastFpsAt); + this.frameCount = 0; + this.lastFpsAt = now; + } + this.lastFrameAt = now; -function syncClock() { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ - type: "clock_ping", - node_id: nodeId, - client_send_ms: clientNowMs(), - client_send_perf_ms: performance.now(), - })); -} + this.drainScheduledJobs(); + this.drawMotion(this.paused ? 0 : this.timelineMs()); + this.updateHud(); + requestAnimationFrame((nextNow) => this.renderLoop(nextNow)); + }, -function handleClockPong(payload) { - const receivePerf = performance.now(); - const sendPerf = Number(payload.client_send_perf_ms ?? payload.client_send_ms); - if (!Number.isFinite(sendPerf)) return; - const rtt = receivePerf - sendPerf; - const midpointPerf = sendPerf + rtt / 2; - const offset = Number(payload.server_time_ms) - midpointPerf; - clockSamples.push({ rtt, offset }); - clockSamples = clockSamples.sort((a, b) => a.rtt - b.rtt).slice(0, 8); - bestRttMs = clockSamples[0].rtt; - serverOffsetFromPerfMs = clockSamples.slice(0, 4).reduce((sum, item) => sum + item.offset, 0) / Math.min(4, clockSamples.length); -} + drawMotion(t) { + const ctx = this.ctx; + if (!ctx || !this.tile) return; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + const count = this.motionMode === "orbit" ? 46 : this.motionMode === "pulse" ? 34 : 28; + ctx.globalAlpha = 0.72; + for (let i = 0; i < count; i += 1) { + const modeFactor = this.motionMode === "pulse" ? 0.84 : this.motionMode === "orbit" ? 1.24 : 1; + const phase = t / (980 / modeFactor) + i * 0.39; + const x = (Math.sin(phase * 0.41 + i) * 0.5 + 0.5) * ctx.canvas.width; + const y = (Math.cos(phase * 0.33 + i * 0.2) * 0.5 + 0.5) * ctx.canvas.height; + const radius = 240 + (i % 7) * 74; + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.2); + gradient.addColorStop(0, i % 3 ? "rgba(56,189,248,0.22)" : "rgba(20,184,166,0.24)"); + gradient.addColorStop(0.42, "rgba(37,99,235,0.08)"); + gradient.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(x, y, radius * 3.2, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + }, -function sendAck(message, status) { - if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return; - ws.send(JSON.stringify({ - type: "ack", - command_id: message.command_id, - node_id: nodeId, - tile_id: tile.id, - status, - client_time_ms: clientNowMs(), - server_estimated_ms: serverNowMs(), - clock_offset_ms: serverOffsetFromPerfMs - (Date.now() - performance.now()), - rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null, - })); -} + pulse() { + this.pulseActive = false; + requestAnimationFrame(() => { + this.pulseActive = true; + setTimeout(() => { + this.pulseActive = false; + }, 560); + }); + }, -function sendTelemetry() { - if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return; - ws.send(JSON.stringify({ - type: "telemetry", - node_id: nodeId, - tile_id: tile.id, - fps, - frame_time_ms: frameTimeMs, - dropped_frames: droppedFrames, - })); -} + syncClock() { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ + type: "clock_ping", + node_id: this.nodeId, + client_send_ms: Date.now(), + client_send_perf_ms: performance.now(), + })); + }, -function connectWs() { - const protocol = location.protocol === "https:" ? "wss" : "ws"; - ws = new WebSocket(`${protocol}://${location.host}/ws/output/${tile.id}`); - ws.addEventListener("open", () => { - ws.send(JSON.stringify({ - type: "hello", - node_id: nodeId, - tile_id: tile.id, - app_version: APP_VERSION, - user_agent: navigator.userAgent, - })); - for (let i = 0; i < 5; i += 1) setTimeout(syncClock, i * 120); - }); - ws.addEventListener("close", () => setTimeout(connectWs, 1200)); - ws.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "clock_pong") handleClockPong(message.payload); - if (message.type === "state") scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => applyState(message.payload.state || message.payload)); - if (message.type === "prepare_scene") prepareScene(message); - if (message.type === "commit_scene") commitScene(message); - if (message.type === "component_action") runComponentAction(message); - }); -} + handleClockPong(payload) { + const receivePerf = performance.now(); + const sendPerf = Number(payload.client_send_perf_ms); + const sendWall = Number(payload.client_send_ms); + const serverTime = Number(payload.server_time_ms); + if (!Number.isFinite(sendWall) || !Number.isFinite(serverTime)) return; + const perfRtt = Number.isFinite(sendPerf) ? receivePerf - sendPerf : Number.NaN; + const wallRtt = Date.now() - sendWall; + const rtt = Number.isFinite(perfRtt) && perfRtt >= 0 ? perfRtt : wallRtt; + const midpointWall = sendWall + rtt / 2; + const offset = serverTime - midpointWall; + this.clockSamples.push({ rtt, offset }); + this.clockSamples = this.clockSamples.sort((a, b) => a.rtt - b.rtt).slice(0, 8); + this.bestRttMs = this.clockSamples[0].rtt; + this.serverOffsetFromDateMs = this.clockSamples + .slice(0, 4) + .reduce((sum, item) => sum + item.offset, 0) / Math.min(4, this.clockSamples.length); + }, -function updateHud() { - if (!tile) return; - const rtt = Number.isFinite(bestRttMs) ? `${Math.round(bestRttMs)}ms` : "--"; - const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--scale")); - const preview = scale < 0.75 ? " | 本地预览已缩小,生产 4K 拼接桌面会更清晰" : ""; - hud.innerHTML = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt} | jobs ${scheduledJobs.size}${preview}`; -} + serverNowMs() { + return Date.now() + this.serverOffsetFromDateMs; + }, -function tickClock() { - clockText.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false }); -} + sendAck(message, status, extra = {}) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.tile) return; + this.ws.send(JSON.stringify({ + type: "ack", + command_id: message.command_id, + node_id: this.nodeId, + tile_id: this.tile.id, + status, + client_time_ms: Date.now(), + server_estimated_ms: this.serverNowMs(), + clock_offset_ms: this.serverOffsetFromDateMs, + rtt_ms: Number.isFinite(this.bestRttMs) ? this.bestRttMs : null, + ...extra, + })); + }, -window.addEventListener("resize", layout); -window.addEventListener("keydown", (event) => { - if (event.key.toLowerCase() === "h") hud.hidden = !hud.hidden; -}); + sendTelemetry() { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.tile) return; + this.ws.send(JSON.stringify({ + type: "telemetry", + node_id: this.nodeId, + tile_id: this.tile.id, + fps: this.fps, + frame_time_ms: this.frameTimeMs, + dropped_frames: this.droppedFrames, + })); + }, -bootstrap(); -tickClock(); -setInterval(tickClock, 1000); -setInterval(syncClock, 2000); -setInterval(sendTelemetry, 1000); + connectWs() { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + this.ws = new WebSocket(`${protocol}://${location.host}/ws/output/${this.tile.id}`); + this.ws.addEventListener("open", () => { + this.ws.send(JSON.stringify({ + type: "hello", + node_id: this.nodeId, + tile_id: this.tile.id, + app_version: APP_VERSION, + user_agent: navigator.userAgent, + })); + for (let i = 0; i < 5; i += 1) setTimeout(() => this.syncClock(), i * 120); + }); + this.ws.addEventListener("close", () => setTimeout(() => this.connectWs(), 1200)); + this.ws.addEventListener("message", (event) => this.handleOutputMessage(JSON.parse(event.data))); + }, + + connectPreviewWs() { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + this.ws = new WebSocket(`${protocol}://${location.host}/ws/admin`); + this.ws.addEventListener("open", () => { + for (let i = 0; i < 5; i += 1) setTimeout(() => this.syncClock(), i * 120); + }); + this.ws.addEventListener("close", () => setTimeout(() => this.connectPreviewWs(), 1200)); + this.ws.addEventListener("message", (event) => this.handlePreviewMessage(JSON.parse(event.data))); + }, + + handleOutputMessage(message) { + if (message.type === "clock_pong") this.handleClockPong(message.payload); + if (message.type === "state") this.scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => this.applyState(message.payload.state || message.payload)); + if (message.type === "prepare_scene") void this.prepareScene(message); + if (message.type === "commit_scene") this.commitScene(message); + if (message.type === "component_action") this.runComponentAction(message); + }, + + handlePreviewMessage(message) { + if (message.type === "clock_pong") this.handleClockPong(message.payload); + if (message.type === "state") this.scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => this.applyState(message.payload.state || message.payload)); + if (message.type === "prepare_scene") this.commitScenePreview(message); + if (message.type === "commit_scene") this.commitScenePreview(message); + if (message.type === "component_action") this.runComponentActionPreview(message); + }, + + updateHud() { + if (!this.tile) return; + const rtt = Number.isFinite(this.bestRttMs) ? `${Math.round(this.bestRttMs)}ms` : "--"; + const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--scale")); + const previewNote = scale < 0.75 ? " | 本地预览已缩小,生产 4K 拼接桌面会更清晰" : ""; + const mode = isPreview ? "PREVIEW" : this.nodeId.slice(0, 18); + this.hudText = `${this.tile.id} | ${mode} | ${this.motionMode} | ${this.fps.toFixed(1)}fps | ${this.frameTimeMs.toFixed(1)}ms | rtt ${rtt} | jobs ${this.scheduledJobs.size}${previewNote}`; + }, + + tickClock() { + this.clockText = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + }, + }, +}).mount("#outputApp"); diff --git a/led_platform/web/static/output/styles.css b/led_platform/web/static/output/styles.css index 1ba8390..457daaf 100644 --- a/led_platform/web/static/output/styles.css +++ b/led_platform/web/static/output/styles.css @@ -1,10 +1,12 @@ :root { color-scheme: dark; font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; - background: #030712; + background: #020617; color: #f8fbff; --wall-width: 14880px; --wall-height: 3510px; + --tile-render-width: 100vw; + --tile-render-height: 100vh; --scale: 1; --offset-x: 0px; --offset-y: 0px; @@ -14,9 +16,12 @@ box-sizing: border-box; } +[v-cloak] { + display: none; +} + html, -body, -.viewport { +body { width: 100%; height: 100%; margin: 0; @@ -24,54 +29,70 @@ body, } body { - background: #030712; + display: grid; + place-items: center; + background: #020617; } .viewport { - position: fixed; - inset: 0; - background: #030712; + position: relative; + width: var(--tile-render-width); + height: var(--tile-render-height); + max-width: 100vw; + max-height: 100vh; + overflow: hidden; + background: #020617; } .wall { position: absolute; - left: 0; - top: 0; + left: var(--offset-x); + top: var(--offset-y); width: var(--wall-width); height: var(--wall-height); overflow: hidden; transform-origin: 0 0; - transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale)); + transform: scale(var(--scale)); background: - radial-gradient(circle at 50% 40%, rgba(20, 184, 166, 0.28), transparent 36%), - radial-gradient(circle at 82% 22%, rgba(249, 115, 22, 0.18), transparent 24%), - linear-gradient(135deg, #061528 0%, #0b1220 48%, #071018 100%); + radial-gradient(circle at 14% 16%, rgba(14, 165, 233, 0.22), transparent 23%), + radial-gradient(circle at 78% 28%, rgba(20, 184, 166, 0.18), transparent 26%), + linear-gradient(135deg, #020b1f 0%, #06172d 46%, #020617 100%); } .motion-canvas, -.grid-layer { +.grid-layer, +.scan-layer { position: absolute; inset: 0; } .grid-layer { background-image: - linear-gradient(rgba(255,255,255,0.045) 2px, transparent 2px), - linear-gradient(90deg, rgba(255,255,255,0.045) 2px, transparent 2px); + linear-gradient(rgba(125, 211, 252, 0.045) 2px, transparent 2px), + linear-gradient(90deg, rgba(125, 211, 252, 0.045) 2px, transparent 2px); background-size: 240px 240px; - opacity: 0.74; + opacity: 0.86; +} + +.scan-layer { + opacity: 0.34; + background: + linear-gradient(90deg, transparent 0 47%, rgba(56, 189, 248, 0.16) 50%, transparent 53%), + repeating-linear-gradient(0deg, transparent 0 120px, rgba(125, 211, 252, 0.035) 124px 128px); + background-size: 2400px 100%, 100% 100%; + animation: scan 10s linear infinite; } .screen-header { position: absolute; - z-index: 4; - left: 360px; - right: 360px; - top: 180px; + z-index: 5; + left: 340px; + right: 340px; + top: 160px; display: flex; justify-content: space-between; align-items: flex-start; - gap: 120px; + gap: 160px; } h1, @@ -82,44 +103,47 @@ p { } .eyebrow { - margin-bottom: 38px; - color: #5eead4; - font-size: 58px; + margin-bottom: 34px; + color: #67e8f9; + font-size: 54px; line-height: 1; text-transform: uppercase; } h1 { - font-size: 178px; + color: #f8fbff; + font-size: 172px; line-height: 1; - font-weight: 820; - color: #ffffff; - text-shadow: 0 0 36px rgba(20, 184, 166, 0.46); + font-weight: 860; + text-shadow: 0 0 44px rgba(56, 189, 248, 0.5); } .header-metrics { display: grid; justify-items: end; - gap: 26px; + gap: 24px; + color: #dbeafe; } .header-metrics strong { - font-size: 112px; + font-size: 108px; line-height: 1; } -.header-metrics span { - color: #cbd5e1; - font-size: 52px; +.header-metrics span, +.header-metrics em { + font-size: 48px; + font-style: normal; + color: #93c5fd; } .scene { position: absolute; - z-index: 3; - inset: 620px 360px 250px; + z-index: 4; + inset: 580px 340px 230px; opacity: 0; pointer-events: none; - transform: translateY(54px); + transform: translateY(70px); transition: opacity 260ms linear, transform 260ms linear; } @@ -130,209 +154,227 @@ h1 { } .hero-copy { - max-width: 6200px; + max-width: 6400px; +} + +.hero-copy span { + display: inline-block; + margin-bottom: 34px; + color: #38bdf8; + font-size: 56px; + font-weight: 760; } .hero-copy h2 { - font-size: 158px; + font-size: 156px; line-height: 1.05; - font-weight: 820; + font-weight: 860; } .hero-copy p { - margin-top: 44px; + margin-top: 42px; + max-width: 5800px; color: #dbeafe; - font-size: 66px; - line-height: 1.34; + font-size: 64px; + line-height: 1.32; } -.card-grid { - margin-top: 150px; +.metric-grid { + position: absolute; + left: 0; + right: 0; + top: 760px; display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 72px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 58px; } .metric-card, -.side-stats article, -.camera-card { - border: 4px solid rgba(94, 234, 212, 0.32); +.stage-node, +.event-card { + border: 4px solid rgba(125, 211, 252, 0.28); border-radius: 8px; - background: rgba(15, 23, 42, 0.76); - box-shadow: inset 0 0 60px rgba(20, 184, 166, 0.10), 0 0 42px rgba(20, 184, 166, 0.12); + background: + linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(8, 47, 73, 0.62)), + rgba(15, 23, 42, 0.8); + box-shadow: + inset 0 0 76px rgba(14, 165, 233, 0.13), + 0 0 54px rgba(14, 165, 233, 0.16); } .metric-card { - min-height: 520px; - padding: 74px; + min-height: 430px; + padding: 62px; } .metric-card span, -.side-stats span { +.event-card span { display: block; - color: #a7f3d0; - font-size: 58px; + color: #7dd3fc; + font-size: 48px; } -.metric-card strong, -.side-stats strong { +.metric-card strong { display: block; - margin-top: 48px; + margin-top: 36px; color: #ffffff; - font-size: 136px; + font-size: 122px; line-height: 1; } -.timeline-bars { +.metric-card small { + display: block; + margin-top: 28px; + color: #a7f3d0; + font-size: 44px; +} + +.visual-stage { position: absolute; left: 0; - right: 0; - bottom: 0; - height: 960px; - display: grid; - grid-template-columns: repeat(32, 1fr); - align-items: end; - gap: 24px; + top: 1360px; + width: 11200px; + height: 1320px; } -.timeline-bars i { - min-height: 120px; - border-radius: 8px 8px 0 0; - background: linear-gradient(180deg, #14b8a6, #f97316); - transform-origin: bottom; -} - -.flow-map { +.route-field { position: absolute; - left: 0; - top: 790px; - width: 8600px; - height: 1760px; + inset: 0; } -.flow-line { +.route-line { position: absolute; - height: 30px; + height: 28px; border-radius: 8px; - background: linear-gradient(90deg, #14b8a6, #facc15, #f97316); - box-shadow: 0 0 90px rgba(20, 184, 166, 0.46); + background: linear-gradient(90deg, transparent, #38bdf8, #2dd4bf, transparent); + box-shadow: 0 0 80px rgba(56, 189, 248, 0.46); + transform-origin: left center; + animation: routePulse 3.8s ease-in-out infinite; } -.line-a { - left: 1100px; - top: 690px; - width: 5200px; - transform: rotate(7deg); -} +.line-a { left: 620px; top: 210px; width: 6000px; transform: rotate(8deg); } +.line-b { left: 980px; top: 680px; width: 7600px; transform: rotate(-7deg); } +.line-c { left: 2600px; top: 1020px; width: 6400px; transform: rotate(5deg); } +.line-d { left: 1400px; top: 420px; width: 8800px; transform: rotate(0deg); } +.line-e { left: 2200px; top: 840px; width: 6600px; transform: rotate(-12deg); } +.line-f { left: 540px; top: 1060px; width: 9200px; transform: rotate(10deg); } -.line-b { - left: 2300px; - top: 1050px; - width: 4200px; - transform: rotate(-10deg); -} - -.flow-node { +.stage-node { position: absolute; - width: 980px; - height: 420px; + width: 1120px; + min-height: 390px; + padding: 60px; display: grid; - place-items: center; - border: 5px solid rgba(94, 234, 212, 0.62); - border-radius: 8px; - background: rgba(8, 20, 28, 0.92); - font-size: 80px; - font-weight: 780; + align-content: center; + gap: 26px; } -.node-a { left: 220px; top: 520px; } -.node-b { left: 3600px; top: 220px; } -.node-c { left: 7020px; top: 900px; } +.stage-node span { + color: #bfdbfe; + font-size: 58px; +} -.side-stats { +.stage-node strong { + color: #ffffff; + font-size: 84px; + line-height: 1; +} + +.stage-node.cyan { + border-color: rgba(34, 211, 238, 0.48); +} + +.stage-node.blue { + border-color: rgba(96, 165, 250, 0.48); +} + +.stage-node.orange { + border-color: rgba(251, 146, 60, 0.56); +} + +.event-rail { position: absolute; right: 0; - top: 690px; - width: 4700px; + top: 1340px; + width: 3600px; display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 64px; + gap: 34px; } -.side-stats article { - min-height: 620px; - padding: 78px; +.event-card { + padding: 46px 56px; + border-left: 18px solid #38bdf8; } -.camera-grid { - position: absolute; - left: 0; - top: 760px; - width: 9200px; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 52px; +.event-card strong { + display: block; + margin-top: 18px; + color: #ffffff; + font-size: 60px; } -.camera-card { - height: 700px; - padding: 58px; - display: grid; - align-content: space-between; - background: - linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)), - rgba(15,23,42,0.78); +.event-card small { + display: block; + margin-top: 18px; + color: #cbd5e1; + font-size: 42px; } -.camera-card strong { - font-size: 78px; +.motion-pulse .route-line { + animation-duration: 1.8s; } -.camera-card span { - color: #dbeafe; - font-size: 52px; +.motion-orbit .stage-node { + animation: nodeFloat 4.6s ease-in-out infinite; } -.alert-rail { - position: absolute; - right: 0; - top: 740px; - width: 4300px; - display: grid; - gap: 44px; +.focus-edge .stage-node.orange, +.focus-core .stage-node.cyan { + box-shadow: + inset 0 0 96px rgba(125, 211, 252, 0.22), + 0 0 120px rgba(56, 189, 248, 0.38); } -.alert-card { - padding: 58px 68px; - border-left: 20px solid #f97316; - border-radius: 8px; - background: rgba(15, 23, 42, 0.78); - font-size: 64px; -} +.theme-energy .event-card { border-left-color: #2dd4bf; } +.theme-security .event-card { border-left-color: #fb923c; } +.theme-command .event-card { border-left-color: #60a5fa; } +.theme-transport .event-card { border-left-color: #38bdf8; } +.theme-dataflow .event-card { border-left-color: #22d3ee; } .hud { position: fixed; right: 12px; bottom: 12px; z-index: 10; - max-width: min(980px, calc(100vw - 24px)); + max-width: min(1120px, calc(100vw - 24px)); padding: 9px 11px; border-radius: 6px; - background: rgba(0,0,0,0.72); - color: rgba(255,255,255,0.82); + background: rgba(0, 0, 0, 0.72); + color: rgba(255, 255, 255, 0.84); font-size: 12px; pointer-events: none; } -.preview-note { - color: #facc15; +.pulse { + animation: flash 560ms ease; } -.pulse { - animation: flash 520ms ease; +@keyframes scan { + from { background-position: -2400px 0, 0 0; } + to { background-position: 2400px 0, 0 0; } +} + +@keyframes routePulse { + 0%, 100% { opacity: 0.36; filter: brightness(0.9); } + 50% { opacity: 1; filter: brightness(1.35); } +} + +@keyframes nodeFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-26px); } } @keyframes flash { - 0% { outline: 18px solid rgba(94, 234, 212, 0.55); } - 100% { outline: 0 solid rgba(94, 234, 212, 0); } + 0% { outline: 18px solid rgba(56, 189, 248, 0.55); } + 100% { outline: 0 solid rgba(56, 189, 248, 0); } } diff --git a/tests/test_sync.py b/tests/test_sync.py index 648f4f1..643af73 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -33,6 +33,38 @@ def test_sync_coordinator_marks_command_complete_after_both_tiles_ack(): assert coordinator.status()["commands"][0]["complete"] +def test_sync_coordinator_releases_scene_commit_after_both_tiles_prepare(): + coordinator = SyncCoordinator(["left", "right"], command_ttl_seconds=30) + message = CommandEnvelope( + type=CommandType.COMMIT_SCENE, + payload={"apply_at_ms": 2000, "state": {"active_scene_id": "overview"}}, + ) + coordinator.register_command(message, message.payload) + + coordinator.ack( + { + "command_id": message.command_id, + "node_id": "left-node", + "tile_id": "left", + "status": "prepared", + } + ) + assert coordinator.release_if_prepared(message.command_id) is None + + coordinator.ack( + { + "command_id": message.command_id, + "node_id": "right-node", + "tile_id": "right", + "status": "prepared", + } + ) + released = coordinator.release_if_prepared(message.command_id) + assert released is not None + assert released.released_at_ms is not None + assert coordinator.release_if_prepared(message.command_id) is None + + def test_sync_coordinator_tracks_node_clock_and_frame_metrics(): coordinator = SyncCoordinator(["left", "right"], command_ttl_seconds=30) coordinator.connect_node(node_id="left-node", tile_id="left", app_version="test")