Init
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
logs/
|
||||||
|
runtime/
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# LED 大屏投放平台
|
||||||
|
|
||||||
|
面向 `14880 x 3510` LED 大屏的双 GPU 服务器投放平台。
|
||||||
|
|
||||||
|
当前架构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
控制服务
|
||||||
|
-> WebSocket 同步协议
|
||||||
|
-> 左 GPU 服务器本地渲染 left tile
|
||||||
|
-> 右 GPU 服务器本地渲染 right tile
|
||||||
|
-> 每台服务器 4 路 4K 输出
|
||||||
|
-> 拼控 / LED 控制器物理拼接
|
||||||
|
```
|
||||||
|
|
||||||
|
WebSocket 只传场景、动作、时间轴、ACK 和性能指标,不传超大视频流。画面由两台 GPU 服务器各自在本地渲染。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
第一次启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
python -m led_platform.cli serve
|
||||||
|
```
|
||||||
|
|
||||||
|
已有环境后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m led_platform.cli serve
|
||||||
|
```
|
||||||
|
|
||||||
|
或者直接用 uvicorn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn led_platform.main:app --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
查看地址:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m led_platform.cli urls
|
||||||
|
```
|
||||||
|
|
||||||
|
常用地址:
|
||||||
|
|
||||||
|
- 控制台: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
|
||||||
|
|
||||||
|
生产输出端直接用浏览器打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
左 GPU 服务器:http://控制服务IP:8000/output/left
|
||||||
|
右 GPU 服务器:http://控制服务IP:8000/output/right
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作
|
||||||
|
|
||||||
|
1. 启动控制服务。
|
||||||
|
2. 打开控制台 `http://127.0.0.1:8000/`。
|
||||||
|
3. 左输出端打开 `/output/left`。
|
||||||
|
4. 右输出端打开 `/output/right`。
|
||||||
|
5. 在控制台点击场景切换或局部动作。
|
||||||
|
6. 观察控制台右侧的节点、FPS、frame time、RTT、clock offset、ACK 状态。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
led_platform/
|
||||||
|
api/ HTTP 和 WebSocket 路由
|
||||||
|
core/ 配置、时钟
|
||||||
|
domain/ 协议和领域模型
|
||||||
|
services/ 场景、tile、同步协调、WebSocket hub
|
||||||
|
web/static/ 控制台和输出端前端
|
||||||
|
main.py FastAPI 应用入口
|
||||||
|
cli.py 跨平台命令行入口
|
||||||
|
runtime.py 应用依赖装配
|
||||||
|
config/
|
||||||
|
scenes.json 场景配置
|
||||||
|
tiles.json 左右 GPU 服务器和 4 路 4K 输出映射
|
||||||
|
tests/
|
||||||
|
test_api.py
|
||||||
|
test_sync.py
|
||||||
|
test_tiles.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q
|
||||||
|
python -m compileall led_platform tests
|
||||||
|
node --check led_platform/web/static/output/main.js
|
||||||
|
node --check led_platform/web/static/admin/main.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"default_scene_id": "overview",
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"id": "overview",
|
||||||
|
"name": "总览大屏",
|
||||||
|
"url": "/wall-app",
|
||||||
|
"view": "overview",
|
||||||
|
"description": "双 GPU 本地渲染、统一时间轴、左右 tile 裁剪的默认场景",
|
||||||
|
"preload": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "energy",
|
||||||
|
"name": "能源驾驶舱",
|
||||||
|
"url": "/wall-app",
|
||||||
|
"view": "energy",
|
||||||
|
"description": "能源、功率、储能和调度策略场景",
|
||||||
|
"preload": ["energy-models", "energy-textures"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "security",
|
||||||
|
"name": "安防态势",
|
||||||
|
"url": "/wall-app",
|
||||||
|
"view": "security",
|
||||||
|
"description": "视频墙、告警、地图联动场景",
|
||||||
|
"preload": ["camera-grid", "security-map"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"wall": {
|
||||||
|
"width": 14880,
|
||||||
|
"height": 3510
|
||||||
|
},
|
||||||
|
"tiles": [
|
||||||
|
{
|
||||||
|
"id": "left",
|
||||||
|
"name": "Left GPU server",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 7440,
|
||||||
|
"height": 3510,
|
||||||
|
"wall_width": 14880,
|
||||||
|
"wall_height": 3510,
|
||||||
|
"desktop_width": 7680,
|
||||||
|
"desktop_height": 4320,
|
||||||
|
"physical_outputs": [
|
||||||
|
{ "id": "left-1", "index": 1, "x": 0, "y": 0, "width": 3840, "height": 2160, "connector": "HDMI/DP-1" },
|
||||||
|
{ "id": "left-2", "index": 2, "x": 3840, "y": 0, "width": 3840, "height": 2160, "connector": "HDMI/DP-2" },
|
||||||
|
{ "id": "left-3", "index": 3, "x": 0, "y": 2160, "width": 3840, "height": 2160, "connector": "HDMI/DP-3" },
|
||||||
|
{ "id": "left-4", "index": 4, "x": 3840, "y": 2160, "width": 3840, "height": 2160, "connector": "HDMI/DP-4" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "right",
|
||||||
|
"name": "Right GPU server",
|
||||||
|
"x": 7440,
|
||||||
|
"y": 0,
|
||||||
|
"width": 7440,
|
||||||
|
"height": 3510,
|
||||||
|
"wall_width": 14880,
|
||||||
|
"wall_height": 3510,
|
||||||
|
"desktop_width": 7680,
|
||||||
|
"desktop_height": 4320,
|
||||||
|
"physical_outputs": [
|
||||||
|
{ "id": "right-1", "index": 1, "x": 0, "y": 0, "width": 3840, "height": 2160, "connector": "HDMI/DP-1" },
|
||||||
|
{ "id": "right-2", "index": 2, "x": 3840, "y": 0, "width": 3840, "height": 2160, "connector": "HDMI/DP-2" },
|
||||||
|
{ "id": "right-3", "index": 3, "x": 0, "y": 2160, "width": 3840, "height": 2160, "connector": "HDMI/DP-3" },
|
||||||
|
{ "id": "right-4", "index": 4, "x": 3840, "y": 2160, "width": 3840, "height": 2160, "connector": "HDMI/DP-4" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 生产架构说明
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将一个前端大屏应用稳定投放到 `14880 x 3510` LED 大屏。
|
||||||
|
|
||||||
|
硬件约束:
|
||||||
|
|
||||||
|
- 两台 GPU 服务器。
|
||||||
|
- 每台服务器 4 路 4K 输出。
|
||||||
|
- 两台服务器品牌或 GPU 可能不同,无法依赖硬件级 GPU 同步。
|
||||||
|
|
||||||
|
因此系统目标不是让两张 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 左右屏如何分开
|
||||||
|
|
||||||
|
完整逻辑画面:
|
||||||
|
|
||||||
|
```text
|
||||||
|
|-------------------------- 14880 --------------------------|
|
||||||
|
|------------ left 7440 ------------|-------- right 7440 ----|
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/tiles.json` 定义:
|
||||||
|
|
||||||
|
```text
|
||||||
|
left:
|
||||||
|
x=0, y=0, width=7440, height=3510
|
||||||
|
|
||||||
|
right:
|
||||||
|
x=7440, y=0, width=7440, height=3510
|
||||||
|
```
|
||||||
|
|
||||||
|
输出端页面创建完整逻辑大屏坐标系,然后根据自己的 tile 做视口偏移。真实 Three.js 项目中,应使用:
|
||||||
|
|
||||||
|
```js
|
||||||
|
camera.setViewOffset(
|
||||||
|
fullWidth,
|
||||||
|
fullHeight,
|
||||||
|
tile.x,
|
||||||
|
tile.y,
|
||||||
|
tile.width,
|
||||||
|
tile.height
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 每台服务器 4 路 4K
|
||||||
|
|
||||||
|
每台服务器建议配置为 `2 x 2` 逻辑桌面:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7680 x 4320
|
||||||
|
```
|
||||||
|
|
||||||
|
四路输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1: x=0, y=0, 3840 x 2160
|
||||||
|
2: x=3840, y=0, 3840 x 2160
|
||||||
|
3: x=0, y=2160, 3840 x 2160
|
||||||
|
4: x=3840, y=2160, 3840 x 2160
|
||||||
|
```
|
||||||
|
|
||||||
|
单侧业务 tile 是 `7440 x 3510`,可以放入该逻辑桌面。
|
||||||
|
|
||||||
|
## 同步协议
|
||||||
|
|
||||||
|
场景切换:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/scenes/{scene_id}/switch
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端广播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
prepare_scene(command_id, apply_at_ms)
|
||||||
|
commit_scene(command_id, apply_at_ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
输出端流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 收到 prepare,记录命令,可预加载资源。
|
||||||
|
2. 收到 commit,等到 apply_at_ms。
|
||||||
|
3. 到点提交场景。
|
||||||
|
4. 回 ACK。
|
||||||
|
```
|
||||||
|
|
||||||
|
局部动作:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
支持结构化动作:
|
||||||
|
|
||||||
|
```text
|
||||||
|
page.next
|
||||||
|
page.prev
|
||||||
|
energy.mode
|
||||||
|
security.alert
|
||||||
|
timeline.pause
|
||||||
|
timeline.resume
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键渲染原则
|
||||||
|
|
||||||
|
所有动画、Three.js、地图和视频都应基于统一时间轴:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const t = serverNowMs() - sceneStartedAtMs;
|
||||||
|
renderSceneAt(t);
|
||||||
|
```
|
||||||
|
|
||||||
|
不要让左右服务器各自自由播放:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 不推荐
|
||||||
|
animation += localDeltaTime;
|
||||||
|
```
|
||||||
|
|
||||||
|
这样即使某台机器偶尔慢一帧,也会在下一帧追到统一时间,不会越播越偏。
|
||||||
|
|
||||||
|
## 为什么不推荐单机渲染后网络分发
|
||||||
|
|
||||||
|
单侧半屏未压缩数据量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7440 x 3510 x 4 bytes x 60fps ~= 6.3 GB/s
|
||||||
|
```
|
||||||
|
|
||||||
|
左右两侧合计超过 `12 GB/s`。普通网络视频流需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。
|
||||||
|
|
||||||
|
## 生产验收指标
|
||||||
|
|
||||||
|
建议关注 P95/P99,而不是平均值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
60 FPS:
|
||||||
|
P95 frame time < 16.67ms
|
||||||
|
P99 frame time < 20ms
|
||||||
|
|
||||||
|
30 FPS:
|
||||||
|
P95 frame time < 33.33ms
|
||||||
|
P99 frame time < 40ms
|
||||||
|
```
|
||||||
|
|
||||||
|
同步指标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket RTT < 10ms
|
||||||
|
clock offset < 5ms
|
||||||
|
left/right 都返回 committed 或 action_committed
|
||||||
|
不得频繁出现 late ACK
|
||||||
|
```
|
||||||
|
|
||||||
|
硬件建议:
|
||||||
|
|
||||||
|
- 两台输出服务器尽量使用同型号 GPU、同驱动、同浏览器版本。
|
||||||
|
- 电源策略设置为高性能。
|
||||||
|
- 关闭系统休眠、屏保、自动更新弹窗。
|
||||||
|
- 拼控侧开启输入缓存、帧同步或延迟对齐能力。
|
||||||
|
- 生产环境使用 PTP 或内网 NTP。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
能保证:
|
||||||
|
|
||||||
|
- 两边业务状态一致。
|
||||||
|
- 两边按同一服务端时间点提交。
|
||||||
|
- 动画长期不漂移。
|
||||||
|
- 命令和性能可观测。
|
||||||
|
|
||||||
|
不能单独保证:
|
||||||
|
|
||||||
|
- 两张不同 GPU 每一帧物理扫描完全同相。
|
||||||
|
|
||||||
|
如果必须达到广播级帧同步,需要硬件层支持 Genlock / Frame Lock / 专业视频墙控制器。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""LED wall projection platform."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""HTTP and WebSocket API routes."""
|
||||||
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
|
||||||
|
from led_platform.core.clock import epoch_ms
|
||||||
|
from led_platform.domain import CommandEnvelope, CommandType, PerformanceSample
|
||||||
|
from led_platform.runtime import hub, scenes, settings, sync, tiles
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WEB_DIR = ROOT / "led_platform" / "web"
|
||||||
|
STATIC_DIR = WEB_DIR / "static"
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
ALLOWED_ACTIONS = {
|
||||||
|
"page.next",
|
||||||
|
"page.prev",
|
||||||
|
"energy.mode",
|
||||||
|
"security.alert",
|
||||||
|
"timeline.pause",
|
||||||
|
"timeline.resume",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", include_in_schema=False)
|
||||||
|
async def admin_index() -> FileResponse:
|
||||||
|
return FileResponse(STATIC_DIR / "admin" / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/output/{tile_id}", include_in_schema=False)
|
||||||
|
async def output_index(tile_id: str) -> FileResponse:
|
||||||
|
try:
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wall-app", include_in_schema=False)
|
||||||
|
async def wall_app() -> FileResponse:
|
||||||
|
return FileResponse(STATIC_DIR / "output" / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/static/favicon.svg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthz")
|
||||||
|
async def healthz() -> dict:
|
||||||
|
sync_status = sync.status()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
"clients": await hub.counts(),
|
||||||
|
"outputs": len(sync_status["nodes"]),
|
||||||
|
"pending_commands": len(sync_status["commands"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/bootstrap/{tile_id}")
|
||||||
|
async def bootstrap(tile_id: str) -> dict:
|
||||||
|
try:
|
||||||
|
tile = tiles.get(tile_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc
|
||||||
|
return {
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
"target_fps": settings.target_fps,
|
||||||
|
"tile": tile.model_dump(mode="json"),
|
||||||
|
"tiles": [item.model_dump(mode="json") for item in tiles.all()],
|
||||||
|
"state": scenes.state().model_dump(mode="json"),
|
||||||
|
"scenes": [scene.model_dump(mode="json") for scene in scenes.list()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/scenes")
|
||||||
|
async def list_scenes() -> dict:
|
||||||
|
return {
|
||||||
|
"state": scenes.state().model_dump(mode="json"),
|
||||||
|
"scenes": [scene.model_dump(mode="json") for scene in scenes.list()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/scenes/{scene_id}/switch")
|
||||||
|
async def switch_scene(scene_id: str) -> dict:
|
||||||
|
try:
|
||||||
|
scene = scenes.get(scene_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown scene: {scene_id}") from exc
|
||||||
|
|
||||||
|
apply_at_ms = epoch_ms() + settings.switch_prepare_delay_ms
|
||||||
|
state = scenes.switch(scene.id, apply_at_ms)
|
||||||
|
payload = {
|
||||||
|
"state": state.model_dump(mode="json"),
|
||||||
|
"scene": scene.model_dump(mode="json"),
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
"apply_at_ms": apply_at_ms,
|
||||||
|
}
|
||||||
|
prepare = CommandEnvelope(type=CommandType.PREPARE_SCENE, payload=payload)
|
||||||
|
commit = CommandEnvelope(
|
||||||
|
type=CommandType.COMMIT_SCENE,
|
||||||
|
command_id=prepare.command_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
sync.register_command(commit, payload)
|
||||||
|
await hub.broadcast(prepare, "admin", "outputs")
|
||||||
|
await hub.broadcast(commit, "admin", "outputs")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/actions")
|
||||||
|
async def run_action(payload: dict) -> dict:
|
||||||
|
action = payload.get("action")
|
||||||
|
if not isinstance(action, str) or action not in ALLOWED_ACTIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported action: {action!r}")
|
||||||
|
|
||||||
|
apply_at_ms = epoch_ms() + settings.action_prepare_delay_ms
|
||||||
|
state = scenes.action(action, apply_at_ms)
|
||||||
|
command_payload = {
|
||||||
|
"target": payload.get("target") or "wall",
|
||||||
|
"action": action,
|
||||||
|
"args": payload.get("args") or {},
|
||||||
|
"state": state.model_dump(mode="json"),
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
"apply_at_ms": apply_at_ms,
|
||||||
|
}
|
||||||
|
message = CommandEnvelope(type=CommandType.COMPONENT_ACTION, payload=command_payload)
|
||||||
|
sync.register_command(message, command_payload)
|
||||||
|
await hub.broadcast(message, "admin", "outputs")
|
||||||
|
return command_payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tiles")
|
||||||
|
async def list_tiles() -> dict:
|
||||||
|
return {
|
||||||
|
"wall": {"width": settings.wall_width, "height": settings.wall_height},
|
||||||
|
"tiles": [tile.model_dump(mode="json") for tile in tiles.all()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sync/status")
|
||||||
|
async def sync_status() -> dict:
|
||||||
|
return sync.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/telemetry")
|
||||||
|
async def telemetry(sample: PerformanceSample) -> dict:
|
||||||
|
sync.telemetry(sample)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from led_platform.core.clock import epoch_ms
|
||||||
|
from led_platform.domain import CommandEnvelope, CommandType, PerformanceSample
|
||||||
|
from led_platform.runtime import hub, scenes, settings, sync, tiles
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def initial_state_payload() -> dict:
|
||||||
|
apply_at_ms = epoch_ms() + settings.initial_state_delay_ms
|
||||||
|
state = scenes.state().model_copy(update={"apply_at_ms": apply_at_ms})
|
||||||
|
return {
|
||||||
|
"state": state.model_dump(mode="json"),
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
"apply_at_ms": apply_at_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/admin")
|
||||||
|
async def admin_ws(websocket: WebSocket) -> None:
|
||||||
|
await hub.connect("admin", websocket)
|
||||||
|
try:
|
||||||
|
await hub.send(websocket, CommandEnvelope(type=CommandType.STATE, payload=initial_state_payload()))
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_json()
|
||||||
|
if raw.get("type") == CommandType.CLOCK_PING:
|
||||||
|
await hub.send(
|
||||||
|
websocket,
|
||||||
|
CommandEnvelope(
|
||||||
|
type=CommandType.CLOCK_PONG,
|
||||||
|
payload={
|
||||||
|
"client_send_ms": raw.get("client_send_ms"),
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
await hub.disconnect("admin", websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/output/{tile_id}")
|
||||||
|
async def output_ws(websocket: WebSocket, tile_id: str) -> None:
|
||||||
|
try:
|
||||||
|
tiles.get(tile_id)
|
||||||
|
except KeyError:
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
node_id: str | None = None
|
||||||
|
await hub.connect("outputs", websocket)
|
||||||
|
try:
|
||||||
|
await hub.send(websocket, CommandEnvelope(type=CommandType.STATE, payload=initial_state_payload()))
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_json()
|
||||||
|
message_type = raw.get("type")
|
||||||
|
|
||||||
|
if message_type == CommandType.HELLO:
|
||||||
|
node_id = str(raw.get("node_id") or f"{tile_id}-anonymous")
|
||||||
|
node = sync.connect_node(
|
||||||
|
node_id=node_id,
|
||||||
|
tile_id=tile_id,
|
||||||
|
app_version=raw.get("app_version"),
|
||||||
|
user_agent=raw.get("user_agent"),
|
||||||
|
)
|
||||||
|
await hub.broadcast(
|
||||||
|
CommandEnvelope(
|
||||||
|
type=CommandType.HEARTBEAT,
|
||||||
|
payload=node.model_dump(mode="json"),
|
||||||
|
),
|
||||||
|
"admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message_type == CommandType.CLOCK_PING:
|
||||||
|
await hub.send(
|
||||||
|
websocket,
|
||||||
|
CommandEnvelope(
|
||||||
|
type=CommandType.CLOCK_PONG,
|
||||||
|
payload={
|
||||||
|
"node_id": raw.get("node_id") or node_id,
|
||||||
|
"client_send_ms": raw.get("client_send_ms"),
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message_type == CommandType.ACK:
|
||||||
|
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)
|
||||||
|
await hub.broadcast(
|
||||||
|
CommandEnvelope(
|
||||||
|
type=CommandType.ACK,
|
||||||
|
payload={**raw, "record": record.model_dump(mode="json") if record else None},
|
||||||
|
),
|
||||||
|
"admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message_type == CommandType.TELEMETRY:
|
||||||
|
if raw.get("node_id") and raw.get("tile_id"):
|
||||||
|
sync.telemetry(
|
||||||
|
PerformanceSample(
|
||||||
|
node_id=raw["node_id"],
|
||||||
|
tile_id=raw["tile_id"],
|
||||||
|
fps=float(raw.get("fps") or 0),
|
||||||
|
frame_time_ms=float(raw.get("frame_time_ms") or 0),
|
||||||
|
dropped_frames=int(raw.get("dropped_frames") or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
sync.disconnect_node(node_id)
|
||||||
|
await hub.disconnect("outputs", websocket)
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import webbrowser
|
||||||
|
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from led_platform.core.config import get_settings
|
||||||
|
|
||||||
|
app = typer.Typer(help="LED wall projection platform CLI.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def serve(
|
||||||
|
host: str = typer.Option(None, help="Bind host."),
|
||||||
|
port: int = typer.Option(None, help="Bind port."),
|
||||||
|
reload: bool = typer.Option(False, help="Enable uvicorn reload."),
|
||||||
|
) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run(
|
||||||
|
"led_platform.main:app",
|
||||||
|
host=host or settings.app_host,
|
||||||
|
port=port or settings.app_port,
|
||||||
|
reload=reload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def urls(
|
||||||
|
base: str = typer.Option("http://127.0.0.1:8000", help="Controller base URL."),
|
||||||
|
) -> None:
|
||||||
|
typer.echo(f"Admin: {base}/")
|
||||||
|
typer.echo(f"Left output: {base}/output/left")
|
||||||
|
typer.echo(f"Right output:{base}/output/right")
|
||||||
|
typer.echo(f"Health: {base}/healthz")
|
||||||
|
typer.echo(f"Sync: {base}/api/sync/status")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def open(
|
||||||
|
base: str = typer.Option("http://127.0.0.1:8000", help="Controller base URL."),
|
||||||
|
target: str = typer.Option("admin", help="admin, left, right, sync"),
|
||||||
|
) -> None:
|
||||||
|
targets = {
|
||||||
|
"admin": f"{base}/",
|
||||||
|
"left": f"{base}/output/left",
|
||||||
|
"right": f"{base}/output/right",
|
||||||
|
"sync": f"{base}/api/sync/status",
|
||||||
|
}
|
||||||
|
url = targets.get(target)
|
||||||
|
if not url:
|
||||||
|
raise typer.BadParameter("target must be one of: admin, left, right, sync")
|
||||||
|
webbrowser.open(url)
|
||||||
|
typer.echo(url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Core platform utilities."""
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
|
||||||
|
def epoch_ms() -> int:
|
||||||
|
return int(time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_host: str = "127.0.0.1"
|
||||||
|
app_port: int = 8000
|
||||||
|
public_base_url: str = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
wall_width: int = 14880
|
||||||
|
wall_height: int = 3510
|
||||||
|
target_fps: int = 60
|
||||||
|
|
||||||
|
switch_prepare_delay_ms: int = 1400
|
||||||
|
action_prepare_delay_ms: int = 650
|
||||||
|
initial_state_delay_ms: int = 160
|
||||||
|
command_ttl_seconds: int = 45
|
||||||
|
|
||||||
|
scene_config: Path = Path("config/scenes.json")
|
||||||
|
tile_config: Path = Path("config/tiles.json")
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="LED_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from .models import (
|
||||||
|
AckStatus,
|
||||||
|
CommandEnvelope,
|
||||||
|
CommandRecord,
|
||||||
|
CommandType,
|
||||||
|
NodeRole,
|
||||||
|
NodeStatus,
|
||||||
|
OutputNode,
|
||||||
|
PerformanceSample,
|
||||||
|
PhysicalOutput,
|
||||||
|
Scene,
|
||||||
|
SceneState,
|
||||||
|
Tile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AckStatus",
|
||||||
|
"CommandEnvelope",
|
||||||
|
"CommandRecord",
|
||||||
|
"CommandType",
|
||||||
|
"NodeRole",
|
||||||
|
"NodeStatus",
|
||||||
|
"OutputNode",
|
||||||
|
"PerformanceSample",
|
||||||
|
"PhysicalOutput",
|
||||||
|
"Scene",
|
||||||
|
"SceneState",
|
||||||
|
"Tile",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl
|
||||||
|
|
||||||
|
from led_platform.core.clock import epoch_ms
|
||||||
|
|
||||||
|
|
||||||
|
class CommandType(str, Enum):
|
||||||
|
HELLO = "hello"
|
||||||
|
STATE = "state"
|
||||||
|
PREPARE_SCENE = "prepare_scene"
|
||||||
|
COMMIT_SCENE = "commit_scene"
|
||||||
|
COMPONENT_ACTION = "component_action"
|
||||||
|
CLOCK_PING = "clock_ping"
|
||||||
|
CLOCK_PONG = "clock_pong"
|
||||||
|
TELEMETRY = "telemetry"
|
||||||
|
HEARTBEAT = "heartbeat"
|
||||||
|
ACK = "ack"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class AckStatus(str, Enum):
|
||||||
|
PREPARED = "prepared"
|
||||||
|
COMMITTED = "committed"
|
||||||
|
ACTION_COMMITTED = "action_committed"
|
||||||
|
LATE = "late"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class NodeRole(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
OUTPUT = "output"
|
||||||
|
RENDERER = "renderer"
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStatus(str, Enum):
|
||||||
|
STARTING = "starting"
|
||||||
|
READY = "ready"
|
||||||
|
DEGRADED = "degraded"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class PhysicalOutput(BaseModel):
|
||||||
|
id: str
|
||||||
|
index: int
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int = 3840
|
||||||
|
height: int = 2160
|
||||||
|
connector: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Tile(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
wall_width: int
|
||||||
|
wall_height: int
|
||||||
|
desktop_width: int
|
||||||
|
desktop_height: int
|
||||||
|
physical_outputs: list[PhysicalOutput] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def right(self) -> int:
|
||||||
|
return self.x + self.width
|
||||||
|
|
||||||
|
|
||||||
|
class Scene(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
url: str | HttpUrl = "/wall-app"
|
||||||
|
view: str
|
||||||
|
description: str | None = None
|
||||||
|
preload: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneState(BaseModel):
|
||||||
|
active_scene_id: str
|
||||||
|
active_view: str
|
||||||
|
active_url: str
|
||||||
|
version: int
|
||||||
|
scene_started_at_ms: int
|
||||||
|
apply_at_ms: int | None = None
|
||||||
|
action: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommandEnvelope(BaseModel):
|
||||||
|
type: CommandType
|
||||||
|
command_id: str = Field(default_factory=lambda: uuid4().hex)
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
sent_at_ms: int = Field(default_factory=epoch_ms)
|
||||||
|
|
||||||
|
|
||||||
|
class OutputNode(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
tile_id: str
|
||||||
|
status: NodeStatus = NodeStatus.READY
|
||||||
|
connected_at_ms: int = Field(default_factory=epoch_ms)
|
||||||
|
last_seen_ms: int = Field(default_factory=epoch_ms)
|
||||||
|
app_version: str | None = None
|
||||||
|
user_agent: str | None = None
|
||||||
|
clock_offset_ms: float | None = None
|
||||||
|
rtt_ms: float | None = None
|
||||||
|
fps: float | None = None
|
||||||
|
frame_time_ms: float | None = None
|
||||||
|
dropped_frames: int | None = None
|
||||||
|
gpu_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceSample(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
tile_id: str
|
||||||
|
fps: float
|
||||||
|
frame_time_ms: float
|
||||||
|
dropped_frames: int = 0
|
||||||
|
at_ms: int = Field(default_factory=epoch_ms)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRecord(BaseModel):
|
||||||
|
command_id: str
|
||||||
|
command_type: CommandType
|
||||||
|
target_tiles: list[str]
|
||||||
|
apply_at_ms: int
|
||||||
|
created_at_ms: int = Field(default_factory=epoch_ms)
|
||||||
|
expires_at_ms: int
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
acks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def complete(self) -> bool:
|
||||||
|
completed = {
|
||||||
|
ack.get("tile_id")
|
||||||
|
for ack in self.acks
|
||||||
|
if ack.get("status") in {AckStatus.COMMITTED, AckStatus.ACTION_COMMITTED}
|
||||||
|
}
|
||||||
|
return set(self.target_tiles).issubset({str(tile) for tile in completed if tile})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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.include_router(http_router)
|
||||||
|
app.include_router(ws_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run("led_platform.main:app", host=settings.app_host, port=settings.app_port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from led_platform.core.config import get_settings
|
||||||
|
from led_platform.services.scene_store import SceneStore
|
||||||
|
from led_platform.services.sync_coordinator import SyncCoordinator
|
||||||
|
from led_platform.services.tile_store import TileStore
|
||||||
|
from led_platform.services.ws_hub import WebSocketHub
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
tiles = TileStore(settings.tile_config, settings.wall_width, settings.wall_height)
|
||||||
|
scenes = SceneStore(settings.scene_config)
|
||||||
|
hub = WebSocketHub()
|
||||||
|
sync = SyncCoordinator(tiles.ids(), settings.command_ttl_seconds)
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Application services."""
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from led_platform.core.clock import epoch_ms
|
||||||
|
from led_platform.domain import Scene, SceneState
|
||||||
|
|
||||||
|
|
||||||
|
class SceneStore:
|
||||||
|
def __init__(self, config_path: Path) -> None:
|
||||||
|
self.config_path = config_path
|
||||||
|
self._lock = Lock()
|
||||||
|
self._scenes: dict[str, Scene] = {}
|
||||||
|
self._state: SceneState | None = None
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
data = json.loads(self.config_path.read_text(encoding="utf-8"))
|
||||||
|
scenes = [Scene(**item) for item in data["scenes"]]
|
||||||
|
if not scenes:
|
||||||
|
raise ValueError("At least one scene is required.")
|
||||||
|
default_scene_id = data.get("default_scene_id") or scenes[0].id
|
||||||
|
scene_map = {scene.id: scene for scene in scenes}
|
||||||
|
if default_scene_id not in scene_map:
|
||||||
|
raise ValueError(f"Default scene {default_scene_id!r} does not exist.")
|
||||||
|
default_scene = scene_map[default_scene_id]
|
||||||
|
with self._lock:
|
||||||
|
version = self._state.version + 1 if self._state else 1
|
||||||
|
self._scenes = scene_map
|
||||||
|
self._state = self._build_state(default_scene, version=version, apply_at_ms=None)
|
||||||
|
|
||||||
|
def list(self) -> list[Scene]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._scenes.values())
|
||||||
|
|
||||||
|
def get(self, scene_id: str) -> Scene:
|
||||||
|
with self._lock:
|
||||||
|
if scene_id not in self._scenes:
|
||||||
|
raise KeyError(scene_id)
|
||||||
|
return self._scenes[scene_id]
|
||||||
|
|
||||||
|
def state(self) -> SceneState:
|
||||||
|
with self._lock:
|
||||||
|
if self._state is None:
|
||||||
|
raise RuntimeError("Scene state is not initialized.")
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def switch(self, scene_id: str, apply_at_ms: int) -> SceneState:
|
||||||
|
with self._lock:
|
||||||
|
if scene_id not in self._scenes:
|
||||||
|
raise KeyError(scene_id)
|
||||||
|
version = self._state.version + 1 if self._state else 1
|
||||||
|
self._state = self._build_state(
|
||||||
|
self._scenes[scene_id],
|
||||||
|
version=version,
|
||||||
|
apply_at_ms=apply_at_ms,
|
||||||
|
)
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def action(self, action: str, apply_at_ms: int) -> SceneState:
|
||||||
|
with self._lock:
|
||||||
|
if self._state is None:
|
||||||
|
raise RuntimeError("Scene state is not initialized.")
|
||||||
|
self._state = self._state.model_copy(
|
||||||
|
update={
|
||||||
|
"version": self._state.version + 1,
|
||||||
|
"action": action,
|
||||||
|
"apply_at_ms": apply_at_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_state(scene: Scene, version: int, apply_at_ms: int | None) -> SceneState:
|
||||||
|
return SceneState(
|
||||||
|
active_scene_id=scene.id,
|
||||||
|
active_view=scene.view,
|
||||||
|
active_url=str(scene.url),
|
||||||
|
version=version,
|
||||||
|
scene_started_at_ms=apply_at_ms or epoch_ms(),
|
||||||
|
apply_at_ms=apply_at_ms,
|
||||||
|
)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from led_platform.core.clock import epoch_ms
|
||||||
|
from led_platform.domain import CommandEnvelope, CommandRecord, OutputNode, PerformanceSample
|
||||||
|
|
||||||
|
|
||||||
|
class SyncCoordinator:
|
||||||
|
def __init__(self, target_tiles: list[str], command_ttl_seconds: int) -> None:
|
||||||
|
self.target_tiles = target_tiles
|
||||||
|
self.command_ttl_seconds = command_ttl_seconds
|
||||||
|
self._lock = Lock()
|
||||||
|
self._nodes: dict[str, OutputNode] = {}
|
||||||
|
self._commands: dict[str, CommandRecord] = {}
|
||||||
|
|
||||||
|
def connect_node(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
node_id: str,
|
||||||
|
tile_id: str,
|
||||||
|
app_version: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> OutputNode:
|
||||||
|
with self._lock:
|
||||||
|
node = OutputNode(
|
||||||
|
node_id=node_id,
|
||||||
|
tile_id=tile_id,
|
||||||
|
app_version=app_version,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
self._nodes[node_id] = node
|
||||||
|
return node
|
||||||
|
|
||||||
|
def disconnect_node(self, node_id: str | None) -> None:
|
||||||
|
if not node_id:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._nodes.pop(node_id, None)
|
||||||
|
|
||||||
|
def register_command(self, message: CommandEnvelope, payload: dict) -> CommandRecord:
|
||||||
|
with self._lock:
|
||||||
|
self._prune_locked()
|
||||||
|
apply_at_ms = int(payload["apply_at_ms"])
|
||||||
|
record = CommandRecord(
|
||||||
|
command_id=message.command_id,
|
||||||
|
command_type=message.type,
|
||||||
|
target_tiles=self.target_tiles,
|
||||||
|
apply_at_ms=apply_at_ms,
|
||||||
|
expires_at_ms=epoch_ms() + self.command_ttl_seconds * 1000,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self._commands[message.command_id] = record
|
||||||
|
return record
|
||||||
|
|
||||||
|
def ack(self, payload: dict) -> CommandRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
self._prune_locked()
|
||||||
|
command_id = payload.get("command_id")
|
||||||
|
if not isinstance(command_id, str) or command_id not in self._commands:
|
||||||
|
return None
|
||||||
|
ack_payload = {**payload, "server_received_ms": epoch_ms()}
|
||||||
|
record = self._commands[command_id]
|
||||||
|
record.acks = [
|
||||||
|
ack for ack in record.acks if ack.get("node_id") != ack_payload.get("node_id")
|
||||||
|
]
|
||||||
|
record.acks.append(ack_payload)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def clock_quality(
|
||||||
|
self,
|
||||||
|
node_id: str | None,
|
||||||
|
clock_offset_ms: float | None,
|
||||||
|
rtt_ms: float | None,
|
||||||
|
) -> None:
|
||||||
|
if not node_id:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
node = self._nodes.get(node_id)
|
||||||
|
if not node:
|
||||||
|
return
|
||||||
|
node.last_seen_ms = epoch_ms()
|
||||||
|
node.clock_offset_ms = clock_offset_ms
|
||||||
|
node.rtt_ms = rtt_ms
|
||||||
|
|
||||||
|
def telemetry(self, sample: PerformanceSample) -> None:
|
||||||
|
with self._lock:
|
||||||
|
node = self._nodes.get(sample.node_id)
|
||||||
|
if not node:
|
||||||
|
return
|
||||||
|
node.last_seen_ms = epoch_ms()
|
||||||
|
node.fps = sample.fps
|
||||||
|
node.frame_time_ms = sample.frame_time_ms
|
||||||
|
node.dropped_frames = sample.dropped_frames
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
self._prune_locked()
|
||||||
|
return {
|
||||||
|
"server_time_ms": epoch_ms(),
|
||||||
|
"target_tiles": self.target_tiles,
|
||||||
|
"nodes": [node.model_dump(mode="json") for node in self._nodes.values()],
|
||||||
|
"commands": [
|
||||||
|
{**record.model_dump(mode="json"), "complete": record.complete}
|
||||||
|
for record in self._commands.values()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _prune_locked(self) -> None:
|
||||||
|
now = epoch_ms()
|
||||||
|
stale = [
|
||||||
|
command_id
|
||||||
|
for command_id, record in self._commands.items()
|
||||||
|
if record.expires_at_ms < now
|
||||||
|
]
|
||||||
|
for command_id in stale:
|
||||||
|
self._commands.pop(command_id, None)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from led_platform.domain import PhysicalOutput, Tile
|
||||||
|
|
||||||
|
|
||||||
|
class TileStore:
|
||||||
|
def __init__(self, config_path: Path, wall_width: int, wall_height: int) -> None:
|
||||||
|
self.config_path = config_path
|
||||||
|
self.wall_width = wall_width
|
||||||
|
self.wall_height = wall_height
|
||||||
|
self._tiles: dict[str, Tile] = {}
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
if self.config_path.exists():
|
||||||
|
data = json.loads(self.config_path.read_text(encoding="utf-8"))
|
||||||
|
tiles = [Tile(**item) for item in data["tiles"]]
|
||||||
|
else:
|
||||||
|
tiles = self._default_tiles()
|
||||||
|
self._tiles = {tile.id: tile for tile in tiles}
|
||||||
|
|
||||||
|
def all(self) -> list[Tile]:
|
||||||
|
return list(self._tiles.values())
|
||||||
|
|
||||||
|
def get(self, tile_id: str) -> Tile:
|
||||||
|
if tile_id not in self._tiles:
|
||||||
|
raise KeyError(tile_id)
|
||||||
|
return self._tiles[tile_id]
|
||||||
|
|
||||||
|
def ids(self) -> list[str]:
|
||||||
|
return list(self._tiles.keys())
|
||||||
|
|
||||||
|
def _default_tiles(self) -> list[Tile]:
|
||||||
|
half_width = self.wall_width // 2
|
||||||
|
return [
|
||||||
|
Tile(
|
||||||
|
id="left",
|
||||||
|
name="Left output server",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=half_width,
|
||||||
|
height=self.wall_height,
|
||||||
|
wall_width=self.wall_width,
|
||||||
|
wall_height=self.wall_height,
|
||||||
|
desktop_width=7680,
|
||||||
|
desktop_height=4320,
|
||||||
|
physical_outputs=self._default_outputs("left"),
|
||||||
|
),
|
||||||
|
Tile(
|
||||||
|
id="right",
|
||||||
|
name="Right output server",
|
||||||
|
x=half_width,
|
||||||
|
y=0,
|
||||||
|
width=self.wall_width - half_width,
|
||||||
|
height=self.wall_height,
|
||||||
|
wall_width=self.wall_width,
|
||||||
|
wall_height=self.wall_height,
|
||||||
|
desktop_width=7680,
|
||||||
|
desktop_height=4320,
|
||||||
|
physical_outputs=self._default_outputs("right"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_outputs(prefix: str) -> list[PhysicalOutput]:
|
||||||
|
return [
|
||||||
|
PhysicalOutput(id=f"{prefix}-1", index=1, x=0, y=0),
|
||||||
|
PhysicalOutput(id=f"{prefix}-2", index=2, x=3840, y=0),
|
||||||
|
PhysicalOutput(id=f"{prefix}-3", index=3, x=0, y=2160),
|
||||||
|
PhysicalOutput(id=f"{prefix}-4", index=4, x=3840, y=2160),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from led_platform.domain import CommandEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketHub:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._groups: dict[str, set[WebSocket]] = defaultdict(set)
|
||||||
|
|
||||||
|
async def connect(self, group: str, websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
async with self._lock:
|
||||||
|
self._groups[group].add(websocket)
|
||||||
|
|
||||||
|
async def disconnect(self, group: str, websocket: WebSocket) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if group in self._groups:
|
||||||
|
self._groups[group].discard(websocket)
|
||||||
|
if not self._groups[group]:
|
||||||
|
self._groups.pop(group, None)
|
||||||
|
|
||||||
|
async def send(self, websocket: WebSocket, message: CommandEnvelope) -> None:
|
||||||
|
await websocket.send_json(message.model_dump(mode="json"))
|
||||||
|
|
||||||
|
async def broadcast(self, message: CommandEnvelope, *groups: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
targets: list[tuple[str, WebSocket]] = []
|
||||||
|
for group in groups:
|
||||||
|
targets.extend((group, websocket) for websocket in self._groups.get(group, set()))
|
||||||
|
|
||||||
|
stale: list[tuple[str, WebSocket]] = []
|
||||||
|
for group, websocket in targets:
|
||||||
|
try:
|
||||||
|
await self.send(websocket, message)
|
||||||
|
except RuntimeError:
|
||||||
|
stale.append((group, websocket))
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
async with self._lock:
|
||||||
|
for group, websocket in stale:
|
||||||
|
self._groups[group].discard(websocket)
|
||||||
|
|
||||||
|
async def counts(self) -> dict[str, int]:
|
||||||
|
async with self._lock:
|
||||||
|
return {group: len(websockets) for group, websockets in self._groups.items()}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>LED Wall Control</title>
|
||||||
|
<link rel="stylesheet" href="/static/admin/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>LED 大屏投放控制台</h1>
|
||||||
|
<p id="stateText">正在连接控制服务...</p>
|
||||||
|
</div>
|
||||||
|
<div class="connection">
|
||||||
|
<span id="wsDot" class="dot"></span>
|
||||||
|
<span id="wsText">WebSocket</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<section class="panel scenes">
|
||||||
|
<div class="panelHead">
|
||||||
|
<h2>场景</h2>
|
||||||
|
<button id="refreshBtn">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div id="sceneList" class="sceneList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panelHead">
|
||||||
|
<h2>输出与同步</h2>
|
||||||
|
</div>
|
||||||
|
<div class="linkGrid">
|
||||||
|
<a href="/output/left" target="_blank">打开左输出</a>
|
||||||
|
<a href="/output/right" target="_blank">打开右输出</a>
|
||||||
|
<a href="/docs" target="_blank">API 文档</a>
|
||||||
|
</div>
|
||||||
|
<div id="syncStatus" class="syncStatus"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panelHead">
|
||||||
|
<h2>局部动作</h2>
|
||||||
|
</div>
|
||||||
|
<div class="actionGrid">
|
||||||
|
<button data-action="page.prev">上一页</button>
|
||||||
|
<button data-action="page.next">下一页</button>
|
||||||
|
<button data-action="energy.mode" data-args='{"mode":"削峰"}'>削峰模式</button>
|
||||||
|
<button data-action="energy.mode" data-args='{"mode":"保供"}'>保供模式</button>
|
||||||
|
<button data-action="security.alert" data-args='{"text":"门禁异常联动"}'>新增告警</button>
|
||||||
|
<button data-action="timeline.pause">暂停时间轴</button>
|
||||||
|
<button data-action="timeline.resume">恢复时间轴</button>
|
||||||
|
</div>
|
||||||
|
<label class="customAction">
|
||||||
|
<span>自定义结构化动作</span>
|
||||||
|
<textarea id="actionInput" rows="5" placeholder='{"action":"security.alert","args":{"text":"消防通道占用"}}'></textarea>
|
||||||
|
</label>
|
||||||
|
<button id="runActionBtn" class="primary">同步执行</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/admin/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
let scenes = [];
|
||||||
|
let state = null;
|
||||||
|
let syncSnapshot = null;
|
||||||
|
|
||||||
|
async function loadScenes() {
|
||||||
|
const res = await fetch("/api/scenes");
|
||||||
|
const data = await res.json();
|
||||||
|
scenes = data.scenes;
|
||||||
|
state = data.state;
|
||||||
|
renderScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSyncStatus() {
|
||||||
|
const res = await fetch("/api/sync/status");
|
||||||
|
syncSnapshot = await res.json();
|
||||||
|
renderSyncStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div>
|
||||||
|
<h3>${scene.name}</h3>
|
||||||
|
<p>${scene.view} | ${scene.url}</p>
|
||||||
|
<p>${scene.description ?? ""}</p>
|
||||||
|
</div>
|
||||||
|
<button data-scene="${scene.id}">切换</button>
|
||||||
|
`;
|
||||||
|
item.querySelector("button").addEventListener("click", () => switchScene(scene.id));
|
||||||
|
sceneList.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSyncStatus() {
|
||||||
|
if (!syncSnapshot) return;
|
||||||
|
const nodes = syncSnapshot.nodes || [];
|
||||||
|
const commands = (syncSnapshot.commands || []).slice(-4).reverse();
|
||||||
|
const rows = [];
|
||||||
|
rows.push(`<div class="syncRow"><span>输出节点</span><strong>${nodes.length}/${syncSnapshot.target_tiles.length}</strong></div>`);
|
||||||
|
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(`<div class="syncRow"><span>${node.tile_id} ${node.node_id.slice(0, 16)}</span><span>${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}</span></div>`);
|
||||||
|
}
|
||||||
|
for (const command of commands) {
|
||||||
|
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
|
||||||
|
rows.push(`<div class="syncRow"><span>${command.command_type} ${command.command_id.slice(0, 8)}</span><span>${command.complete ? "完成" : "进行中"} | ${ackText}</span></div>`);
|
||||||
|
}
|
||||||
|
syncStatus.innerHTML = rows.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (["ack", "heartbeat"].includes(message.type)) {
|
||||||
|
if (message.type === "ack") {
|
||||||
|
wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`;
|
||||||
|
}
|
||||||
|
loadSyncStatus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadScenes();
|
||||||
|
loadSyncStatus();
|
||||||
|
connectWs();
|
||||||
|
setInterval(loadSyncStatus, 3000);
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: #101418;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 184, 166, 0.13), transparent 280px),
|
||||||
|
#101418;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1280px, calc(100vw - 40px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.panelHead,
|
||||||
|
.syncRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #aeb7c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.ok {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenes {
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.linkGrid a {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.linkGrid a:hover {
|
||||||
|
border-color: rgba(20, 184, 166, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
background: #14b8a6;
|
||||||
|
border-color: #14b8a6;
|
||||||
|
color: #071211;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sceneList,
|
||||||
|
.syncStatus,
|
||||||
|
.linkGrid,
|
||||||
|
.actionGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sceneList,
|
||||||
|
.syncStatus,
|
||||||
|
.linkGrid {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene.active {
|
||||||
|
border-color: rgba(20, 184, 166, 0.85);
|
||||||
|
background: rgba(20, 184, 166, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene p {
|
||||||
|
color: #aeb7c2;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.syncRow {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.syncRow strong {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customAction {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.grid,
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenes {
|
||||||
|
grid-row: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="10" fill="#0b1118"/>
|
||||||
|
<rect x="8" y="16" width="48" height="30" rx="3" fill="#14b8a6"/>
|
||||||
|
<rect x="12" y="20" width="18" height="22" fill="#0f172a"/>
|
||||||
|
<rect x="34" y="20" width="18" height="22" fill="#f97316"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,67 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>LED Wall Output</title>
|
||||||
|
<link rel="stylesheet" href="/static/output/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="viewport" class="viewport">
|
||||||
|
<section id="wall" class="wall" aria-label="LED wall output">
|
||||||
|
<canvas id="motionCanvas" class="motionCanvas"></canvas>
|
||||||
|
<div class="gridLayer"></div>
|
||||||
|
|
||||||
|
<header class="wallHeader">
|
||||||
|
<div>
|
||||||
|
<p id="eyebrow" class="eyebrow">Unified Timeline Rendering</p>
|
||||||
|
<h1 id="sceneTitle">LED 大屏投放平台</h1>
|
||||||
|
</div>
|
||||||
|
<div class="metrics">
|
||||||
|
<strong id="clock">--:--:--</strong>
|
||||||
|
<span id="version">v0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="scene active" data-view="overview">
|
||||||
|
<div class="headline">
|
||||||
|
<h2>双 GPU 输出,统一时间轴</h2>
|
||||||
|
<p>左右服务器各自渲染半屏,但所有场景、动画、地图相机、视频时间都由同一个服务端时间轴驱动。</p>
|
||||||
|
</div>
|
||||||
|
<div id="overviewKpis" class="kpiGrid"></div>
|
||||||
|
<div id="overviewBars" class="bars"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="scene" data-view="energy">
|
||||||
|
<div class="headline">
|
||||||
|
<h2>能源驾驶舱</h2>
|
||||||
|
<p>适合 ECharts、Canvas、WebGL 地图和 Three.js 数字孪生。真实项目中应使用 camera.setViewOffset 做 tile-aware 渲染。</p>
|
||||||
|
</div>
|
||||||
|
<div class="energyMap">
|
||||||
|
<div class="flowLine lineA"></div>
|
||||||
|
<div class="flowLine lineB"></div>
|
||||||
|
<div class="flowNode nodeA">园区供电</div>
|
||||||
|
<div class="flowNode nodeB">储能系统</div>
|
||||||
|
<div class="flowNode nodeC">负载中心</div>
|
||||||
|
</div>
|
||||||
|
<div class="sideStats">
|
||||||
|
<article><span>实时功率</span><strong id="powerValue">8.42 MW</strong></article>
|
||||||
|
<article><span>负载趋势</span><strong id="trendValue">+3.8%</strong></article>
|
||||||
|
<article><span>调度策略</span><strong id="modeValue">均衡</strong></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="scene" data-view="security">
|
||||||
|
<div class="headline">
|
||||||
|
<h2>安防态势</h2>
|
||||||
|
<p>视频墙、告警卡片和地图联动都通过结构化动作同步触发,不在输出端执行任意远程脚本。</p>
|
||||||
|
</div>
|
||||||
|
<div id="cameraGrid" class="cameraGrid"></div>
|
||||||
|
<div id="alertRail" class="alertRail"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<aside id="hud" class="hud"></aside>
|
||||||
|
</main>
|
||||||
|
<script src="/static/output/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
const APP_VERSION = "1.0.0";
|
||||||
|
const hud = document.querySelector("#hud");
|
||||||
|
const wall = document.querySelector("#wall");
|
||||||
|
const canvas = document.querySelector("#motionCanvas");
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: true });
|
||||||
|
const clock = document.querySelector("#clock");
|
||||||
|
const version = document.querySelector("#version");
|
||||||
|
const sceneTitle = document.querySelector("#sceneTitle");
|
||||||
|
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
|
||||||
|
|
||||||
|
const sceneNames = {
|
||||||
|
overview: "LED 大屏投放平台",
|
||||||
|
energy: "能源驾驶舱",
|
||||||
|
security: "安防态势",
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let tile = null;
|
||||||
|
let state = null;
|
||||||
|
let paused = false;
|
||||||
|
let pageIndex = 0;
|
||||||
|
let clockOffsetMs = 0;
|
||||||
|
let bestRttMs = Number.POSITIVE_INFINITY;
|
||||||
|
let pendingCommands = new Map();
|
||||||
|
let frameCount = 0;
|
||||||
|
let droppedFrames = 0;
|
||||||
|
let lastFrameAt = performance.now();
|
||||||
|
let lastFpsAt = performance.now();
|
||||||
|
let fps = 0;
|
||||||
|
let frameTimeMs = 0;
|
||||||
|
let nodeId = sessionStorage.getItem("led-platform-node-id");
|
||||||
|
|
||||||
|
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 clientNowMs() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverNowMs() {
|
||||||
|
return clientNowMs() + clockOffsetMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const res = await fetch(`/api/bootstrap/${tileId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
tile = data.tile;
|
||||||
|
applyState(data.state);
|
||||||
|
buildStaticContent();
|
||||||
|
layout();
|
||||||
|
connectWs();
|
||||||
|
requestAnimationFrame(renderLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
updateHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStaticContent() {
|
||||||
|
document.querySelector("#overviewKpis").innerHTML = [
|
||||||
|
["渲染模式", "Local GPU"],
|
||||||
|
["同步方式", "Timeline"],
|
||||||
|
["逻辑宽度", "14,880"],
|
||||||
|
["逻辑高度", "3,510"],
|
||||||
|
["左/右分区", "7,440"],
|
||||||
|
["目标帧率", "60 FPS"],
|
||||||
|
].map(([label, value]) => `<article class="kpi"><span>${label}</span><strong>${value}</strong></article>`).join("");
|
||||||
|
|
||||||
|
const bars = document.querySelector("#overviewBars");
|
||||||
|
bars.innerHTML = "";
|
||||||
|
for (let i = 0; i < 32; i += 1) {
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "bar";
|
||||||
|
bar.style.height = `${180 + ((i * 137) % 760)}px`;
|
||||||
|
bars.append(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => {
|
||||||
|
const id = String(index + 1).padStart(2, "0");
|
||||||
|
return `<article class="camera"><strong>CAM-${id}</strong><span>在线 | 1080P | 低延迟</span></article>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
document.querySelector("#alertRail").innerHTML = [
|
||||||
|
"北侧通道人员聚集",
|
||||||
|
"机房门禁异常",
|
||||||
|
"消防通道占用",
|
||||||
|
"视频质量波动",
|
||||||
|
].map((item) => `<article class="alert">${item}</article>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyState(nextState) {
|
||||||
|
state = nextState;
|
||||||
|
version.textContent = `v${state.version}`;
|
||||||
|
setScene(state.active_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScene(view) {
|
||||||
|
const normalized = view || "overview";
|
||||||
|
document.querySelectorAll(".scene").forEach((scene) => {
|
||||||
|
scene.classList.toggle("active", scene.dataset.view === normalized);
|
||||||
|
});
|
||||||
|
sceneTitle.textContent = sceneNames[normalized] || sceneNames.overview;
|
||||||
|
pulse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAt(applyAtMs, callback) {
|
||||||
|
const leadTimeMs = applyAtMs - serverNowMs();
|
||||||
|
const late = leadTimeMs < 80;
|
||||||
|
setTimeout(() => callback(late), Math.max(0, leadTimeMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareScene(message) {
|
||||||
|
pendingCommands.set(message.command_id, message.payload);
|
||||||
|
sendAck(message, "prepared");
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitScene(message) {
|
||||||
|
const payload = pendingCommands.get(message.command_id) || message.payload;
|
||||||
|
scheduleAt(payload.apply_at_ms, (late) => {
|
||||||
|
applyState(payload.state);
|
||||||
|
pendingCommands.delete(message.command_id);
|
||||||
|
sendAck(message, late ? "late" : "committed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runComponentAction(message) {
|
||||||
|
const { action, args, apply_at_ms: applyAtMs } = message.payload;
|
||||||
|
scheduleAt(applyAtMs, (late) => {
|
||||||
|
applyAction(action, args || {});
|
||||||
|
sendAck(message, late ? "late" : "action_committed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAction(action, args) {
|
||||||
|
if (action === "page.next") {
|
||||||
|
pageIndex += 1;
|
||||||
|
rotateValues();
|
||||||
|
} else if (action === "page.prev") {
|
||||||
|
pageIndex = Math.max(0, pageIndex - 1);
|
||||||
|
rotateValues();
|
||||||
|
} 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 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateValues() {
|
||||||
|
const values = [
|
||||||
|
["8.42 MW", "+3.8%", "均衡"],
|
||||||
|
["7.91 MW", "-1.2%", "削峰"],
|
||||||
|
["9.08 MW", "+6.1%", "保供"],
|
||||||
|
][pageIndex % 3];
|
||||||
|
document.querySelector("#powerValue").textContent = values[0];
|
||||||
|
document.querySelector("#trendValue").textContent = values[1];
|
||||||
|
document.querySelector("#modeValue").textContent = values[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineMs() {
|
||||||
|
if (!state) return 0;
|
||||||
|
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
drawMotion(paused ? 0 : timelineMs());
|
||||||
|
updateAnimatedBars(paused ? 0 : timelineMs());
|
||||||
|
updateHud();
|
||||||
|
requestAnimationFrame(renderLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMotion(t) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.globalAlpha = 0.72;
|
||||||
|
for (let i = 0; i < 24; i += 1) {
|
||||||
|
const phase = (t / 1000) + i * 0.42;
|
||||||
|
const x = (Math.sin(phase * 0.42) * 0.5 + 0.5) * canvas.width;
|
||||||
|
const y = (Math.cos(phase * 0.36) * 0.5 + 0.5) * canvas.height;
|
||||||
|
const r = 80 + (i % 5) * 28;
|
||||||
|
const grad = ctx.createRadialGradient(x, y, 0, x, y, r * 5);
|
||||||
|
grad.addColorStop(0, i % 2 ? "rgba(249,115,22,0.23)" : "rgba(20,184,166,0.25)");
|
||||||
|
grad.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r * 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnimatedBars(t) {
|
||||||
|
document.querySelectorAll(".bar").forEach((bar, index) => {
|
||||||
|
const scale = 0.82 + (Math.sin(t / 760 + index * 0.36) + 1) * 0.16;
|
||||||
|
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulse() {
|
||||||
|
wall.classList.remove("pulse");
|
||||||
|
requestAnimationFrame(() => wall.classList.add("pulse"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHud() {
|
||||||
|
if (!tile) return;
|
||||||
|
const rtt = Number.isFinite(bestRttMs) ? Math.round(bestRttMs) : "--";
|
||||||
|
const offset = `${clockOffsetMs >= 0 ? "+" : ""}${Math.round(clockOffsetMs)}ms`;
|
||||||
|
hud.textContent = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt}ms | offset ${offset}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAck(message, status) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) 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: clockOffsetMs,
|
||||||
|
rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncClock() {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ type: "clock_ping", node_id: nodeId, client_send_ms: clientNowMs() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClockPong(payload) {
|
||||||
|
const receiveMs = clientNowMs();
|
||||||
|
const sendMs = payload.client_send_ms;
|
||||||
|
if (typeof sendMs !== "number") return;
|
||||||
|
const rtt = receiveMs - sendMs;
|
||||||
|
const estimatedServerAtReceive = payload.server_time_ms + rtt / 2;
|
||||||
|
const nextOffset = estimatedServerAtReceive - receiveMs;
|
||||||
|
if (rtt < bestRttMs) {
|
||||||
|
bestRttMs = rtt;
|
||||||
|
clockOffsetMs = nextOffset;
|
||||||
|
} else {
|
||||||
|
clockOffsetMs = clockOffsetMs * 0.85 + nextOffset * 0.15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
syncClock();
|
||||||
|
});
|
||||||
|
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") commitScene({ command_id: message.command_id, payload: message.payload });
|
||||||
|
if (message.type === "prepare_scene") prepareScene(message);
|
||||||
|
if (message.type === "commit_scene") commitScene(message);
|
||||||
|
if (message.type === "component_action") runComponentAction(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickClock() {
|
||||||
|
clock.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", layout);
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key.toLowerCase() === "h") hud.hidden = !hud.hidden;
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
tickClock();
|
||||||
|
setInterval(tickClock, 1000);
|
||||||
|
setInterval(syncClock, 2500);
|
||||||
|
setInterval(sendTelemetry, 1000);
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: #000;
|
||||||
|
color: #f8fafc;
|
||||||
|
--wall-width: 14880px;
|
||||||
|
--wall-height: 3510px;
|
||||||
|
--scale: 1;
|
||||||
|
--offset-x: 0px;
|
||||||
|
--offset-y: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
.viewport {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #020407;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
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));
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(20, 184, 166, 0.14), transparent 34%, rgba(249, 115, 22, 0.12)),
|
||||||
|
#071018;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motionCanvas,
|
||||||
|
.gridLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridLayer {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
|
||||||
|
background-size: 240px 240px;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallHeader {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
left: 360px;
|
||||||
|
right: 360px;
|
||||||
|
top: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 36px;
|
||||||
|
color: #5eead4;
|
||||||
|
font-size: 58px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 178px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics strong {
|
||||||
|
font-size: 108px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics span {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
inset: 620px 360px 240px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(50px);
|
||||||
|
transition: opacity 360ms ease, transform 360ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
max-width: 5900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline h2 {
|
||||||
|
font-size: 156px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline p {
|
||||||
|
margin-top: 42px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 62px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiGrid {
|
||||||
|
margin-top: 140px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi,
|
||||||
|
.sideStats article,
|
||||||
|
.camera {
|
||||||
|
border: 3px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
min-height: 500px;
|
||||||
|
padding: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi span,
|
||||||
|
.sideStats span {
|
||||||
|
display: block;
|
||||||
|
color: #aeb7c2;
|
||||||
|
font-size: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi strong,
|
||||||
|
.sideStats strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 46px;
|
||||||
|
font-size: 130px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 960px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(32, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
min-height: 100px;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
background: linear-gradient(180deg, #14b8a6, #f97316);
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.energyMap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 780px;
|
||||||
|
width: 8600px;
|
||||||
|
height: 1760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLine {
|
||||||
|
position: absolute;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, #14b8a6, #facc15, #f97316);
|
||||||
|
box-shadow: 0 0 80px rgba(20,184,166,0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineA {
|
||||||
|
left: 1100px;
|
||||||
|
top: 690px;
|
||||||
|
width: 5200px;
|
||||||
|
transform: rotate(7deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineB {
|
||||||
|
left: 2300px;
|
||||||
|
top: 1050px;
|
||||||
|
width: 4200px;
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowNode {
|
||||||
|
position: absolute;
|
||||||
|
width: 980px;
|
||||||
|
height: 420px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 5px solid rgba(94,234,212,0.55);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(8,20,28,0.92);
|
||||||
|
font-size: 76px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeA { left: 220px; top: 520px; }
|
||||||
|
.nodeB { left: 3600px; top: 220px; }
|
||||||
|
.nodeC { left: 7020px; top: 900px; }
|
||||||
|
|
||||||
|
.sideStats {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 690px;
|
||||||
|
width: 4700px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideStats article {
|
||||||
|
min-height: 610px;
|
||||||
|
padding: 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameraGrid {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 760px;
|
||||||
|
width: 9200px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera {
|
||||||
|
height: 700px;
|
||||||
|
padding: 52px;
|
||||||
|
display: grid;
|
||||||
|
align-content: space-between;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)),
|
||||||
|
rgba(255,255,255,0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera strong {
|
||||||
|
font-size: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera span {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alertRail {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 740px;
|
||||||
|
width: 4300px;
|
||||||
|
display: grid;
|
||||||
|
gap: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 54px 64px;
|
||||||
|
border-left: 18px solid #f97316;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.065);
|
||||||
|
font-size: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: flash 650ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flash {
|
||||||
|
0% { outline: 16px solid rgba(250,204,21,0.78); }
|
||||||
|
100% { outline: 0 solid rgba(250,204,21,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: min(860px, calc(100vw - 24px));
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0,0,0,0.68);
|
||||||
|
color: rgba(255,255,255,0.78);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "led-platform"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Tile-aware dual GPU-server LED wall projection platform"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi==0.116.1",
|
||||||
|
"uvicorn[standard]==0.35.0",
|
||||||
|
"pydantic==2.11.7",
|
||||||
|
"pydantic-settings==2.10.1",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"websockets==15.0.1",
|
||||||
|
"typer==0.16.0",
|
||||||
|
"rich==14.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest==8.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
led-platform = "led_platform.cli:app"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.116.1
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
pydantic==2.11.7
|
||||||
|
pydantic-settings==2.10.1
|
||||||
|
httpx==0.28.1
|
||||||
|
websockets==15.0.1
|
||||||
|
typer==0.16.0
|
||||||
|
rich==14.0.0
|
||||||
|
pytest==8.4.1
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from led_platform.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_returns_tile_scene_and_timing_data():
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/bootstrap/left")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tile"]["id"] == "left"
|
||||||
|
assert data["state"]["active_view"]
|
||||||
|
assert data["server_time_ms"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch_scene_returns_scheduled_commit_payload():
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/api/scenes/energy/switch")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["state"]["active_scene_id"] == "energy"
|
||||||
|
assert data["state"]["active_view"] == "energy"
|
||||||
|
assert data["apply_at_ms"] > data["server_time_ms"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_rejects_unsupported_action():
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/api/actions", json={"action": "eval.anything"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_status_endpoint_exposes_commands_and_nodes():
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/sync/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "nodes" in data
|
||||||
|
assert "commands" in data
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from led_platform.domain import CommandEnvelope, CommandType, PerformanceSample
|
||||||
|
from led_platform.services.sync_coordinator import SyncCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_coordinator_marks_command_complete_after_both_tiles_ack():
|
||||||
|
coordinator = SyncCoordinator(["left", "right"], command_ttl_seconds=30)
|
||||||
|
message = CommandEnvelope(
|
||||||
|
type=CommandType.COMMIT_SCENE,
|
||||||
|
payload={"apply_at_ms": 2000, "state": {"active_scene_id": "overview"}},
|
||||||
|
)
|
||||||
|
record = coordinator.register_command(message, message.payload)
|
||||||
|
|
||||||
|
assert not record.complete
|
||||||
|
|
||||||
|
coordinator.ack(
|
||||||
|
{
|
||||||
|
"command_id": message.command_id,
|
||||||
|
"node_id": "left-node",
|
||||||
|
"tile_id": "left",
|
||||||
|
"status": "committed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert not coordinator.status()["commands"][0]["complete"]
|
||||||
|
|
||||||
|
coordinator.ack(
|
||||||
|
{
|
||||||
|
"command_id": message.command_id,
|
||||||
|
"node_id": "right-node",
|
||||||
|
"tile_id": "right",
|
||||||
|
"status": "committed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert coordinator.status()["commands"][0]["complete"]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
coordinator.clock_quality("left-node", clock_offset_ms=2.5, rtt_ms=6.0)
|
||||||
|
coordinator.telemetry(
|
||||||
|
PerformanceSample(
|
||||||
|
node_id="left-node",
|
||||||
|
tile_id="left",
|
||||||
|
fps=59.4,
|
||||||
|
frame_time_ms=16.1,
|
||||||
|
dropped_frames=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
node = coordinator.status()["nodes"][0]
|
||||||
|
assert node["clock_offset_ms"] == 2.5
|
||||||
|
assert node["rtt_ms"] == 6.0
|
||||||
|
assert node["fps"] == 59.4
|
||||||
|
assert node["frame_time_ms"] == 16.1
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from led_platform.services.tile_store import TileStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_tiles_cover_wall_as_two_half_width_servers():
|
||||||
|
store = TileStore(Path("config/tiles.json"), wall_width=14880, wall_height=3510)
|
||||||
|
left = store.get("left")
|
||||||
|
right = store.get("right")
|
||||||
|
|
||||||
|
assert left.x == 0
|
||||||
|
assert left.width == 7440
|
||||||
|
assert right.x == 7440
|
||||||
|
assert right.width == 7440
|
||||||
|
assert left.right == right.x
|
||||||
|
assert right.right == 14880
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_server_has_four_4k_physical_outputs():
|
||||||
|
store = TileStore(Path("config/tiles.json"), wall_width=14880, wall_height=3510)
|
||||||
|
|
||||||
|
for tile in store.all():
|
||||||
|
assert tile.desktop_width == 7680
|
||||||
|
assert tile.desktop_height == 4320
|
||||||
|
assert len(tile.physical_outputs) == 4
|
||||||
|
assert all(output.width == 3840 for output in tile.physical_outputs)
|
||||||
|
assert all(output.height == 2160 for output in tile.physical_outputs)
|
||||||
Reference in New Issue
Block a user