Implement synchronized LED wall output

This commit is contained in:
TJY
2026-07-16 12:52:42 +08:00
parent 8590fafac1
commit fe906e028b
11 changed files with 335 additions and 283 deletions
+139 -132
View File
@@ -1,34 +1,36 @@
const APP_VERSION = "1.0.0";
const hud = document.querySelector("#hud");
const APP_VERSION = "3.0.0";
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 hud = document.querySelector("#hud");
const sceneTitle = document.querySelector("#sceneTitle");
const versionText = document.querySelector("#versionText");
const clockText = document.querySelector("#clockText");
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
const sceneNames = {
const sceneTitles = {
overview: "LED 大屏投放平台",
energy: "能源驾驶舱",
security: "安防态势",
};
let ws = null;
let tile = null;
let state = null;
let paused = false;
let pageIndex = 0;
let clockOffsetMs = 0;
let ws = null;
let nodeId = sessionStorage.getItem("led-platform-node-id");
let serverOffsetFromPerfMs = Date.now() - performance.now();
let bestRttMs = Number.POSITIVE_INFINITY;
let pendingCommands = new Map();
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 fps = 0;
let frameTimeMs = 0;
let nodeId = sessionStorage.getItem("led-platform-node-id");
let pageIndex = 0;
let paused = false;
if (!nodeId) {
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
@@ -36,17 +38,17 @@ if (!nodeId) {
sessionStorage.setItem("led-platform-node-id", nodeId);
}
function serverNowMs() {
return performance.now() + serverOffsetFromPerfMs;
}
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();
const response = await fetch(`/api/bootstrap/${tileId}`);
const data = await response.json();
tile = data.tile;
applyState(data.state);
buildStaticContent();
@@ -55,6 +57,37 @@ async function bootstrap() {
requestAnimationFrame(renderLoop);
}
function buildStaticContent() {
document.querySelector("#overviewCards").innerHTML = [
["同步模式", "时间轴"],
["渲染节点", "双 GPU"],
["逻辑宽度", "14880"],
["逻辑高度", "3510"],
["半屏宽度", "7440"],
["目标帧率", "60"],
].map(([label, value]) => `<article class="metric-card"><span>${label}</span><strong>${value}</strong></article>`).join("");
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);
}
document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => {
const id = String(index + 1).padStart(2, "0");
return `<article class="camera-card"><strong>CAM-${id}</strong><span>在线 | 1080P | 低延迟</span></article>`;
}).join("");
document.querySelector("#alertRail").innerHTML = [
"北侧通道人员聚集",
"机房门禁异常",
"消防通道占用",
"视频质量波动",
].map((text) => `<article class="alert-card">${text}</article>`).join("");
}
function layout() {
if (!tile) return;
const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height);
@@ -65,80 +98,58 @@ function layout() {
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";
const view = state.active_view || "overview";
document.querySelectorAll(".scene").forEach((scene) => {
scene.classList.toggle("active", scene.dataset.view === normalized);
scene.classList.toggle("active", scene.dataset.view === view);
});
sceneTitle.textContent = sceneNames[normalized] || sceneNames.overview;
sceneTitle.textContent = sceneTitles[view] || sceneTitles.overview;
versionText.textContent = `v${state.version || 0}`;
pulse();
}
function scheduleAt(applyAtMs, callback) {
const leadTimeMs = applyAtMs - serverNowMs();
const late = leadTimeMs < 80;
setTimeout(() => callback(late), Math.max(0, leadTimeMs));
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(),
});
}
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);
}
}
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);
scheduleJob(message, "scene", (late) => {
applyState(message.payload.state);
sendAck(message, late ? "late" : "committed");
});
}
function runComponentAction(message) {
const { action, args, apply_at_ms: applyAtMs } = message.payload;
scheduleAt(applyAtMs, (late) => {
applyAction(action, args || {});
scheduleJob(message, "action", (late) => {
applyAction(message.payload.action, message.payload.args || {});
sendAck(message, late ? "late" : "action_committed");
});
}
@@ -146,16 +157,18 @@ function runComponentAction(message) {
function applyAction(action, args) {
if (action === "page.next") {
pageIndex += 1;
rotateValues();
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);
rotateValues();
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 pulse";
alert.className = "alert-card pulse";
alert.textContent = args.text || "新增联动告警";
rail.prepend(alert);
while (rail.children.length > 5) rail.lastElementChild.remove();
@@ -167,17 +180,6 @@ function applyAction(action, args) {
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);
@@ -194,34 +196,35 @@ function renderLoop(now) {
}
lastFrameAt = now;
drainScheduledJobs();
drawMotion(paused ? 0 : timelineMs());
updateAnimatedBars(paused ? 0 : timelineMs());
animateBars(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.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, r * 5, 0, Math.PI * 2);
ctx.arc(x, y, radius * 3.4, 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;
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)})`;
});
}
@@ -231,15 +234,31 @@ function 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 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(),
}));
}
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);
}
function sendAck(message, status) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
ws.send(JSON.stringify({
type: "ack",
command_id: message.command_id,
@@ -248,31 +267,11 @@ function sendAck(message, status) {
status,
client_time_ms: clientNowMs(),
server_estimated_ms: serverNowMs(),
clock_offset_ms: clockOffsetMs,
clock_offset_ms: serverOffsetFromPerfMs - (Date.now() - performance.now()),
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({
@@ -296,21 +295,29 @@ function connectWs() {
app_version: APP_VERSION,
user_agent: navigator.userAgent,
}));
syncClock();
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") commitScene({ command_id: message.command_id, payload: 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);
});
}
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}<span class="preview-note">${preview}</span>`;
}
function tickClock() {
clock.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
clockText.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
window.addEventListener("resize", layout);
@@ -321,5 +328,5 @@ window.addEventListener("keydown", (event) => {
bootstrap();
tickClock();
setInterval(tickClock, 1000);
setInterval(syncClock, 2500);
setInterval(syncClock, 2000);
setInterval(sendTelemetry, 1000);