731 lines
41 KiB
JavaScript
731 lines
41 KiB
JavaScript
"use strict";
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const elements = {
|
||
pages: $("pages"), pageTemplate: $("page-template"), cardTemplate: $("card-template"),
|
||
connection: $("connection-status"), filters: $("category-filter"), modeTabs: $("mode-tabs"),
|
||
streamToggle: $("toggle-streams"), pageCount: $("page-count"), caseCount: $("case-count"),
|
||
canvasCount: $("canvas-count"), apiCount: $("api-count"), modeDescription: $("mode-description"),
|
||
menu: $("context-menu"), menuTitle: $("menu-title"), menuComponent: $("menu-component"),
|
||
menuDescription: $("menu-description"), menuBody: $("menu-body"), menuStatus: $("menu-status"),
|
||
menuClose: $("menu-close"), menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast")
|
||
};
|
||
|
||
const query = new URLSearchParams(location.search);
|
||
const hosted = location.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location.pathname);
|
||
const jetBrainsPreview = location.port === "63342";
|
||
const socketPort = query.get("port") || (hosted && !jetBrainsPreview ? location.port : "8848") || "8848";
|
||
const socketHost = query.get("host") || (hosted ? location.hostname : "127.0.0.1") || "127.0.0.1";
|
||
const socketUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${socketHost}:${socketPort}/renderive/gallery`;
|
||
|
||
const pages = new Map();
|
||
let definitions = [];
|
||
let modes = [];
|
||
let activeMode = "low_latency";
|
||
let activeCategory = "全部";
|
||
let activeCard = null;
|
||
let activeTab = "controls";
|
||
let streamsPaused = false;
|
||
let toastTimer = 0;
|
||
let lastAnimationFrameAt = 0;
|
||
let displayIntervalMs = 0;
|
||
let displayIntervalLatestMs = 0;
|
||
let displayIntervalP95Ms = 0;
|
||
let displayJitterMs = 0;
|
||
const displayIntervalSamples = [];
|
||
|
||
const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload});
|
||
const setConnection = (state, text) => {
|
||
elements.connection.dataset.state = state;
|
||
elements.connection.querySelector("span").textContent = text;
|
||
};
|
||
function toast(text, error = false) {
|
||
clearTimeout(toastTimer);
|
||
elements.toast.textContent = text;
|
||
elements.toast.dataset.error = String(error);
|
||
elements.toast.hidden = false;
|
||
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 2600);
|
||
}
|
||
function grouped(items) {
|
||
const groups = new Map();
|
||
for (const item of items || []) {
|
||
const group = item.group || "其他";
|
||
if (!groups.has(group)) groups.set(group, []);
|
||
groups.get(group).push(item);
|
||
}
|
||
return groups;
|
||
}
|
||
function flatten(value, prefix = "", result = []) {
|
||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||
for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result);
|
||
} else result.push([prefix, typeof value === "object" ? JSON.stringify(value) : String(value)]);
|
||
return result;
|
||
}
|
||
function formatNanoseconds(value) {
|
||
const nanoseconds = Math.max(0, Number(value) || 0);
|
||
if (nanoseconds < 1_000) return `${Math.round(nanoseconds)} ns`;
|
||
if (nanoseconds < 1_000_000) return `${(nanoseconds / 1_000).toFixed(2)} µs`;
|
||
return `${(nanoseconds / 1_000_000).toFixed(3)} ms`;
|
||
}
|
||
function updateDisplayTiming(time) {
|
||
if (lastAnimationFrameAt > 0) {
|
||
displayIntervalLatestMs = Math.max(0, time - lastAnimationFrameAt);
|
||
displayIntervalSamples.push({time, interval: displayIntervalLatestMs});
|
||
}
|
||
lastAnimationFrameAt = time;
|
||
while (displayIntervalSamples.length && displayIntervalSamples[0].time < time - 1000) displayIntervalSamples.shift();
|
||
if (!displayIntervalSamples.length) {
|
||
displayIntervalMs = 0;
|
||
displayIntervalP95Ms = 0;
|
||
displayJitterMs = 0;
|
||
return;
|
||
}
|
||
const values = displayIntervalSamples.map(sample => sample.interval).sort((left, right) => left - right);
|
||
const percentile = ratio => values[Math.min(values.length - 1, Math.floor((values.length - 1) * ratio))];
|
||
displayIntervalMs = percentile(0.5);
|
||
displayIntervalP95Ms = percentile(0.95);
|
||
displayJitterMs = Math.max(0, displayIntervalP95Ms - displayIntervalMs);
|
||
}
|
||
|
||
class GalleryCard {
|
||
constructor(definition, mode) {
|
||
this.definition = definition;
|
||
this.mode = mode;
|
||
this.controls = [];
|
||
this.actions = [];
|
||
this.telemetry = {};
|
||
this.frameCount = 0;
|
||
this.framePending = false;
|
||
this.frameTimeout = null;
|
||
this.frameTimeoutCount = 0;
|
||
this.latestPixelBuffer = null;
|
||
this.transportTimes = [];
|
||
this.presentationTimes = [];
|
||
this.transportFps = 0;
|
||
this.presentationFps = 0;
|
||
this.lastPixelSignature = null;
|
||
this.changedPixelFrames = 0;
|
||
this.duplicatePixelFrames = 0;
|
||
this.lastPixelReceivedAt = 0;
|
||
this.lastPixelChangeAt = 0;
|
||
this.motionState = "waiting";
|
||
this.reconnectTimer = null;
|
||
this.disposed = false;
|
||
this.ready = false;
|
||
this.intersecting = false;
|
||
this.backendActive = null;
|
||
this.frameRequestStartedAt = 0;
|
||
this.frameRoundTripMs = 0;
|
||
this.overwrittenPixelFrames = 0;
|
||
this.lastObserveRequest = 0;
|
||
this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true);
|
||
this.node.dataset.category = definition.category;
|
||
this.node.dataset.mode = mode.id;
|
||
this.canvas = this.node.querySelector("canvas");
|
||
this.shell = this.node.querySelector(".canvas-shell");
|
||
this.context = this.canvas.getContext("2d", {alpha: false});
|
||
this.socketState = this.node.querySelector(".card-socket");
|
||
this.motionStatus = this.node.querySelector(".motion-status");
|
||
this.observerFields = new Map([...this.node.querySelectorAll("[data-observer-field]")]
|
||
.map(field => [field.dataset.observerField, field]));
|
||
this.frameLabel = this.node.querySelector(".card-frames");
|
||
this.node.querySelector(".card-category").textContent = definition.category;
|
||
this.node.querySelector(".card-title").textContent = definition.title;
|
||
this.node.querySelector(".card-description").textContent = definition.description;
|
||
this.node.querySelector(".card-component").textContent = definition.component;
|
||
this.node.querySelector(".card-controls").textContent = definition.control_count_by_mode?.[mode.id] ?? "—";
|
||
this.node.querySelector(".card-actions").textContent = definition.action_count_by_mode?.[mode.id] ?? "—";
|
||
const frameButton = this.node.querySelector(".frame-button");
|
||
frameButton.textContent = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新";
|
||
frameButton.addEventListener("click", () => this.requestFrame(performance.now(), true));
|
||
this.node.querySelector(".open-menu").addEventListener("click", event => {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
openMenu(this, rect.right, rect.bottom);
|
||
});
|
||
this.node.addEventListener("contextmenu", event => {
|
||
event.preventDefault();
|
||
openMenu(this, event.clientX, event.clientY);
|
||
});
|
||
this.installCanvasEvents();
|
||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||
this.resizeObserver.observe(this.shell);
|
||
this.intersectionObserver = new IntersectionObserver(entries => {
|
||
const intersecting = entries[0]?.isIntersecting === true;
|
||
if (this.intersecting === intersecting) return;
|
||
this.intersecting = intersecting;
|
||
this.syncActivity();
|
||
}, {rootMargin: "160px"});
|
||
this.intersectionObserver.observe(this.node);
|
||
}
|
||
setSocketState(state, text) {
|
||
this.socketState.dataset.state = state;
|
||
this.socketState.querySelector("span").textContent = text;
|
||
}
|
||
categoryVisible() {
|
||
return activeCategory === "全部" || this.definition.category === activeCategory;
|
||
}
|
||
displayVisible() {
|
||
return !document.hidden && this.mode.id === activeMode && this.categoryVisible() && this.intersecting;
|
||
}
|
||
streamActive() {
|
||
return !streamsPaused && this.displayVisible();
|
||
}
|
||
syncActivity(force = false) {
|
||
const visible = this.displayVisible();
|
||
if (visible && ![WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) {
|
||
this.connect();
|
||
return;
|
||
}
|
||
const active = !streamsPaused && visible;
|
||
if (this.ready && (force || this.backendActive !== active)) {
|
||
this.send(active ? "show" : "hide");
|
||
this.backendActive = active;
|
||
}
|
||
if (active) this.requestFrame(performance.now());
|
||
}
|
||
connect() {
|
||
if (this.disposed || [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) return;
|
||
clearTimeout(this.reconnectTimer);
|
||
const socket = new WebSocket(socketUrl);
|
||
this.socket = socket;
|
||
socket.binaryType = "arraybuffer";
|
||
socket.addEventListener("open", () => {
|
||
if (this.socket !== socket) return;
|
||
this.setSocketState("ready", "WS 已连接");
|
||
this.send("gallery_open", {case: this.definition.id, frame_mode: this.mode.id});
|
||
this.resize();
|
||
});
|
||
socket.addEventListener("message", event => {
|
||
if (this.socket === socket)
|
||
typeof event.data === "string" ? this.receiveJson(event.data) : this.receivePixels(event.data);
|
||
});
|
||
socket.addEventListener("close", () => {
|
||
if (this.socket !== socket) return;
|
||
this.ready = false;
|
||
this.backendActive = null;
|
||
this.framePending = false;
|
||
clearTimeout(this.frameTimeout); this.frameTimeout = null;
|
||
this.setSocketState("error", this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见");
|
||
this.updateMotionStatus();
|
||
if (!this.disposed && this.displayVisible()) this.reconnectTimer = setTimeout(() => this.connect(), 1000);
|
||
});
|
||
socket.addEventListener("error", () => {
|
||
if (this.socket === socket) this.setSocketState("error", "连接错误 · 自动重连");
|
||
});
|
||
}
|
||
send(type, payload = {}) {
|
||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(message(type, payload));
|
||
}
|
||
receiveJson(raw) {
|
||
let data;
|
||
try { data = JSON.parse(raw); } catch { toast(`${this.definition.title} 返回无效 JSON`, true); return; }
|
||
if (data.type === "error") {
|
||
const detail = Object.values(data.field_errors || {})[0] || data.message;
|
||
toast(detail || "后端拒绝操作", true);
|
||
if (activeCard === this) elements.menuStatus.textContent = detail;
|
||
return;
|
||
}
|
||
if (data.type === "observer_state") {
|
||
this.telemetry = data.telemetry || {};
|
||
this.updatePerformance();
|
||
if (activeCard === this && ["observer", "performance"].includes(activeTab)) renderMenuBody();
|
||
return;
|
||
}
|
||
if (data.type !== "case_state") return;
|
||
this.controls = data.controls?.data || [];
|
||
this.actions = data.actions?.data || [];
|
||
this.telemetry = data.telemetry || {};
|
||
this.ready = true;
|
||
this.backendActive = null;
|
||
this.node.dataset.ready = "true";
|
||
this.syncActivity();
|
||
this.node.querySelector(".card-controls").textContent = this.controls.length;
|
||
this.node.querySelector(".card-actions").textContent = this.actions.length;
|
||
this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`);
|
||
this.updatePerformance();
|
||
if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`);
|
||
if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); }
|
||
}
|
||
updatePerformance() {
|
||
const performanceData = this.telemetry.performance || {};
|
||
const observer = this.telemetry.kernel_observer || {};
|
||
const dataShape = this.telemetry.data_shape || {};
|
||
const limit = this.telemetry.low_latency_limit || {};
|
||
const limitNames = {frequency_limited: "Kernel 频率受限", paint_limited: "PaintEvent 受限", render_limited: "后台渲染受限", consumer_limited: "消费者反馈受限", unlimited: "无限制", not_applicable: "N/A"};
|
||
this.node.querySelector(".perf-fps").textContent = Number(performanceData.measured_fps || 0).toFixed(1);
|
||
this.node.querySelector(".perf-transport-fps").textContent = Number(performanceData.pixel_response_fps || 0).toFixed(1);
|
||
this.node.querySelector(".perf-present-fps").textContent = Number(this.presentationFps || 0).toFixed(1);
|
||
this.node.querySelector(".perf-rtt").textContent = Number(this.frameRoundTripMs || 0).toFixed(2);
|
||
this.node.querySelector(".perf-overwritten").textContent = this.overwrittenPixelFrames.toLocaleString();
|
||
this.node.querySelector(".perf-render").textContent = Number(performanceData.last_render_ms || 0).toFixed(2);
|
||
this.node.querySelector(".perf-encode").textContent = Number(performanceData.last_pixel_encode_ms || 0).toFixed(2);
|
||
this.node.querySelector(".perf-bandwidth").textContent = Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1);
|
||
this.node.querySelector(".perf-pending").textContent = observer.pending_frame_count ?? 0;
|
||
this.node.querySelector(".perf-dropped").textContent = observer.dropped_frame_count ?? 0;
|
||
this.node.querySelector(".perf-points").textContent = `${Number(dataShape.input_points || 0).toLocaleString()}→${Number(dataShape.rendered_elements || 0).toLocaleString()}`;
|
||
const limitDurations = {frequency_limited: observer.target_interval_ns, paint_limited: observer.paint_duration_ns, render_limited: observer.render_duration_ns, consumer_limited: observer.consumer_interval_ns};
|
||
const limitName = limitNames[limit.current] || limit.current || "N/A";
|
||
const limitDuration = limitDurations[limit.current];
|
||
const limitValue = limitDuration === undefined ? limitName : `${limitName} · ${formatNanoseconds(limitDuration)}`;
|
||
const limitNode = this.node.querySelector(".perf-limit");
|
||
limitNode.textContent = limitValue;
|
||
limitNode.title = limitDuration === undefined ? limitName : `${limitName} · ${Number(limitDuration || 0).toLocaleString()} ns`;
|
||
this.node.querySelector(".perf-event").textContent = observer.last_event || "none";
|
||
this.node.querySelector(".perf-timeouts").textContent = this.frameTimeoutCount.toLocaleString();
|
||
this.node.querySelector(".perf-pixel-age").textContent = this.lastPixelReceivedAt ? `${Math.max(0, performance.now() - this.lastPixelReceivedAt).toFixed(0)} ms` : "—";
|
||
this.updateObserverDashboard(observer);
|
||
this.updateClientDashboard(this.telemetry.client_performance || {});
|
||
this.updateMotionStatus();
|
||
const setLimitFlag = (selector, enabled, active, duration) => {
|
||
const flag = this.node.querySelector(selector);
|
||
flag.dataset.active = String(Boolean(active));
|
||
flag.querySelector("b").textContent = enabled ? `${active ? "当前瓶颈" : "未受限"} · ${formatNanoseconds(duration)}` : `已关闭 · ${formatNanoseconds(duration)}`;
|
||
};
|
||
setLimitFlag(".limit-frequency", observer.frequency_limit_enabled !== false, limit.current === "frequency_limited", observer.target_interval_ns);
|
||
setLimitFlag(".limit-paint", true, limit.current === "paint_limited", observer.paint_duration_ns);
|
||
setLimitFlag(".limit-render", true, limit.current === "render_limited", observer.render_duration_ns);
|
||
setLimitFlag(".limit-consumer", Boolean(observer.consumer_feedback_enabled), limit.current === "consumer_limited", observer.consumer_interval_ns);
|
||
}
|
||
updateObserverDashboard(observer) {
|
||
const nanosecondFields = new Set([
|
||
"paint_duration_ns", "render_duration_ns", "target_interval_ns",
|
||
"bottleneck_duration_ns", "consumer_pixel_interval_ns", "consumer_presentation_interval_ns",
|
||
"consumer_manual_interval_ns", "consumer_sample_interval_ns", "consumer_smoothed_interval_ns",
|
||
"consumer_variation_ns", "consumer_safety_interval_ns", "consumer_interval_ns", "next_refresh_interval_ns", "paint_lease_wait_ns",
|
||
"paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns",
|
||
"frame_age_at_render_ns", "render_lease_wait_ns", "render_state_wait_ns",
|
||
"render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns"
|
||
]);
|
||
const booleanFields = new Set([
|
||
"consumer_feedback_master_enabled", "consumer_feedback_enabled",
|
||
"consumer_pixel_feedback_enabled", "consumer_presentation_feedback_enabled",
|
||
"consumer_manual_feedback_enabled"
|
||
]);
|
||
for (const [name, field] of this.observerFields) {
|
||
const raw = observer[name] ?? (name === "limit_state" ? "not_applicable" :
|
||
name === "last_event" || name === "consumer_feedback_source" ? "none" : 0);
|
||
if (name === "consumer_effective_fps") {
|
||
const interval = Number(observer.consumer_interval_ns || 0);
|
||
field.textContent = interval > 0 ? `${(1e9 / interval).toFixed(2)} FPS` : "0 FPS";
|
||
field.title = interval > 0 ? `${1e9 / interval} FPS` : "0 FPS";
|
||
continue;
|
||
}
|
||
if (name === "consumer_manual_fps") {
|
||
field.textContent = `${Number(raw || 0).toFixed(2)} FPS`;
|
||
field.title = `${Number(raw || 0)} FPS`;
|
||
continue;
|
||
}
|
||
if (name === "consumer_feedback_source") {
|
||
const names = {disabled: "总开关关闭", none: "无", pixel: "像素响应",
|
||
presentation: "浏览器呈现", manual: "手动"};
|
||
field.textContent = String(raw).split("+").map(value => names[value] || value).join(" + ");
|
||
field.title = String(raw);
|
||
continue;
|
||
}
|
||
if (booleanFields.has(name)) {
|
||
field.textContent = raw ? "启用" : "关闭";
|
||
field.title = String(Boolean(raw));
|
||
continue;
|
||
}
|
||
field.textContent = nanosecondFields.has(name) ? formatNanoseconds(raw) :
|
||
name === "configured_frequency_hz" ? (observer.frequency_limit_enabled === false ? "已关闭" : `${Number(raw || 0).toLocaleString()} Hz`) :
|
||
typeof raw === "number" ? raw.toLocaleString() : String(raw);
|
||
field.title = nanosecondFields.has(name) ? `${Number(raw || 0).toLocaleString()} ns` : String(raw);
|
||
}
|
||
}
|
||
updateClientDashboard(client) {
|
||
for (const field of this.node.querySelectorAll("[data-client-field]")) {
|
||
const name = field.dataset.clientField;
|
||
const raw = Number(client[name] || 0);
|
||
if (name.endsWith("_fps")) field.textContent = `${raw.toFixed(2)} FPS`;
|
||
else if (name.endsWith("_ms")) field.textContent = `${raw.toFixed(3)} ms`;
|
||
else if (name === "websocket_buffered_bytes") field.textContent = `${raw.toLocaleString()} B`;
|
||
else field.textContent = raw.toLocaleString();
|
||
field.title = String(raw);
|
||
}
|
||
}
|
||
pixelSignature(buffer, width, height, stride) {
|
||
const bytes = new Uint8Array(buffer, 16, stride * height);
|
||
let hash = (2166136261 ^ width ^ (height << 16)) >>> 0;
|
||
const sampleCount = Math.min(4096, bytes.length);
|
||
if (sampleCount <= 1) return Math.imul(hash ^ (bytes[0] || 0), 16777619) >>> 0;
|
||
for (let index = 0; index < sampleCount; index++) {
|
||
const offset = Math.floor(index * (bytes.length - 1) / (sampleCount - 1));
|
||
hash = Math.imul(hash ^ bytes[offset], 16777619) >>> 0;
|
||
}
|
||
return hash;
|
||
}
|
||
updateMotionStatus(now = performance.now()) {
|
||
let state = "waiting", label = "等待动态帧";
|
||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) {
|
||
state = "stalled"; label = "像素流断开";
|
||
} else if (!this.streamActive()) {
|
||
state = "waiting"; label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图";
|
||
} else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) {
|
||
state = "moving"; label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`;
|
||
} else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) {
|
||
state = "duplicate"; label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`;
|
||
} else if (this.lastPixelReceivedAt) {
|
||
state = "stalled"; label = "像素帧已停滞";
|
||
}
|
||
if (state !== this.motionState || this.motionStatus.querySelector("span").textContent !== label) {
|
||
this.motionState = state;
|
||
this.motionStatus.dataset.state = state;
|
||
this.motionStatus.querySelector("span").textContent = label;
|
||
}
|
||
}
|
||
receivePixels(buffer) {
|
||
this.framePending = false;
|
||
if (this.frameTimeout !== null) { clearTimeout(this.frameTimeout); this.frameTimeout = null; }
|
||
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) {
|
||
this.setSocketState("error", "像素帧无效 · 自动重连");
|
||
this.socket?.close(1003, "invalid pixel frame");
|
||
return;
|
||
}
|
||
const header = new DataView(buffer, 0, 16);
|
||
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
|
||
const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true);
|
||
if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) {
|
||
this.setSocketState("error", "像素帧协议错误 · 自动重连");
|
||
this.socket?.close(1003, "invalid pixel frame");
|
||
return;
|
||
}
|
||
const now = performance.now();
|
||
const signature = this.pixelSignature(buffer, width, height, stride);
|
||
if (this.lastPixelSignature === null || signature !== this.lastPixelSignature) {
|
||
this.changedPixelFrames++;
|
||
this.lastPixelChangeAt = now;
|
||
} else this.duplicatePixelFrames++;
|
||
this.lastPixelSignature = signature;
|
||
this.lastPixelReceivedAt = now;
|
||
if (this.frameRequestStartedAt > 0) {
|
||
this.frameRoundTripMs = Math.max(0, now - this.frameRequestStartedAt);
|
||
this.frameRequestStartedAt = 0;
|
||
}
|
||
if (this.latestPixelBuffer !== null) this.overwrittenPixelFrames++;
|
||
this.latestPixelBuffer = buffer;
|
||
this.setSocketState("ready", `${this.mode.strategy || "Core2"} 在线`);
|
||
this.transportFps = this.recordRate(this.transportTimes, now);
|
||
this.frameCount++;
|
||
this.frameLabel.textContent = this.frameCount.toLocaleString();
|
||
this.updateMotionStatus(now);
|
||
this.requestFrame(now);
|
||
}
|
||
presentLatest(time) {
|
||
const buffer = this.latestPixelBuffer;
|
||
if (!buffer) return;
|
||
this.latestPixelBuffer = null;
|
||
const header = new DataView(buffer, 0, 16);
|
||
const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true);
|
||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||
this.canvas.width = width;
|
||
this.canvas.height = height;
|
||
}
|
||
if (stride === width * 4) {
|
||
this.context.putImageData(new ImageData(new Uint8ClampedArray(buffer, 16, width * height * 4), width, height), 0, 0);
|
||
} else {
|
||
const packed = new Uint8ClampedArray(width * height * 4), source = new Uint8Array(buffer, 16);
|
||
for (let row = 0; row < height; row++) packed.set(source.subarray(row * stride, row * stride + width * 4), row * width * 4);
|
||
this.context.putImageData(new ImageData(packed, width, height), 0, 0);
|
||
}
|
||
this.presentationFps = this.recordRate(this.presentationTimes, time);
|
||
}
|
||
currentRate(history, now) {
|
||
while (history.length && history[0] < now - 1000) history.shift();
|
||
if (history.length < 2) return 0;
|
||
return (history.length - 1) * 1000 / Math.max(1, history[history.length - 1] - history[0]);
|
||
}
|
||
recordRate(history, now) {
|
||
history.push(now);
|
||
return this.currentRate(history, now);
|
||
}
|
||
resize() {
|
||
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
||
const rect = this.shell.getBoundingClientRect();
|
||
this.send("resize", {width: Math.max(240, Math.round(rect.width)), height: Math.max(180, Math.round(rect.height))});
|
||
}
|
||
requestFrame(time, explicit = false) {
|
||
if ((!explicit && !this.streamActive()) || (explicit && !this.displayVisible()) || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return;
|
||
if (!explicit && this.mode.id === "manual") return;
|
||
this.frameRequestStartedAt = time;
|
||
this.framePending = true;
|
||
this.send("frame");
|
||
this.frameTimeout = setTimeout(() => {
|
||
this.framePending = false;
|
||
this.frameRequestStartedAt = 0;
|
||
this.frameTimeout = null;
|
||
this.frameTimeoutCount++;
|
||
if (!this.streamActive() || this.socket?.readyState !== WebSocket.OPEN) return;
|
||
this.setSocketState("error", "像素响应超时 · 正在恢复");
|
||
this.syncActivity(true);
|
||
}, 1500);
|
||
}
|
||
observe(time) {
|
||
if (!this.ready || !this.displayVisible() || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return;
|
||
this.lastObserveRequest = time;
|
||
this.transportFps = this.currentRate(this.transportTimes, time);
|
||
this.presentationFps = this.currentRate(this.presentationTimes, time);
|
||
this.send("gallery_observe", {client_metrics: {
|
||
transport_fps: this.transportFps,
|
||
presentation_fps: this.presentationFps,
|
||
websocket_buffered_bytes: this.socket.bufferedAmount || 0,
|
||
changed_pixel_frames: this.changedPixelFrames,
|
||
duplicate_pixel_frames: this.duplicatePixelFrames,
|
||
frame_request_timeout_count: this.frameTimeoutCount,
|
||
frame_round_trip_ms: this.frameRoundTripMs,
|
||
display_interval_ms: displayIntervalMs,
|
||
display_interval_latest_ms: displayIntervalLatestMs,
|
||
display_interval_p95_ms: displayIntervalP95Ms,
|
||
display_jitter_ms: displayJitterMs,
|
||
overwritten_pixel_frames: this.overwrittenPixelFrames,
|
||
last_pixel_receive_age_ms: this.lastPixelReceivedAt ? Math.max(0, time - this.lastPixelReceivedAt) : 0,
|
||
last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0
|
||
}});
|
||
}
|
||
position(event) {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
return {x: (event.clientX - rect.left) * this.canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * this.canvas.height / Math.max(1, rect.height)};
|
||
}
|
||
modifiers(event) { return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); }
|
||
installCanvasEvents() {
|
||
const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)});
|
||
this.canvas.addEventListener("pointermove", event => pointer("pointer_move", event));
|
||
this.canvas.addEventListener("pointerdown", event => { if (event.button !== 2) { this.shell.focus(); this.canvas.setPointerCapture(event.pointerId); pointer("pointer_press", event); } });
|
||
this.canvas.addEventListener("pointerup", event => { if (event.button !== 2) pointer("pointer_release", event); });
|
||
this.canvas.addEventListener("pointerleave", () => this.send("leave"));
|
||
this.canvas.addEventListener("wheel", event => { event.preventDefault(); this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); }, {passive: false});
|
||
this.shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)}));
|
||
this.shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)}));
|
||
}
|
||
}
|
||
|
||
function createPage(mode) {
|
||
const page = elements.pageTemplate.content.firstElementChild.cloneNode(true);
|
||
page.dataset.mode = mode.id;
|
||
page.querySelector(".page-strategy").textContent = mode.strategy;
|
||
page.querySelector(".page-title").textContent = `${mode.title} · 全控件页`;
|
||
page.querySelector(".page-description").textContent = mode.description;
|
||
const gallery = page.querySelector(".gallery");
|
||
const cards = definitions.map(definition => new GalleryCard(definition, mode));
|
||
cards.forEach(card => gallery.append(card.node));
|
||
elements.pages.append(page);
|
||
pages.set(mode.id, {mode, page, cards});
|
||
return pages.get(mode.id);
|
||
}
|
||
function syncCardActivity() {
|
||
for (const page of pages.values()) for (const card of page.cards) card.syncActivity();
|
||
}
|
||
function selectMode(id) {
|
||
activeMode = id;
|
||
let selected = pages.get(id);
|
||
if (!selected) selected = createPage(modes.find(mode => mode.id === id));
|
||
for (const [modeId, page] of pages) page.page.hidden = modeId !== id;
|
||
[...elements.modeTabs.children].forEach(button => button.classList.toggle("active", button.dataset.mode === id));
|
||
elements.modeDescription.textContent = selected.mode.description;
|
||
applyCategory();
|
||
syncCardActivity();
|
||
closeMenu();
|
||
}
|
||
function applyCategory() {
|
||
const page = pages.get(activeMode);
|
||
if (!page) return;
|
||
for (const card of page.cards) {
|
||
card.node.hidden = !card.categoryVisible();
|
||
card.syncActivity();
|
||
}
|
||
}
|
||
function installNavigation() {
|
||
elements.modeTabs.replaceChildren(...modes.map(mode => {
|
||
const button = document.createElement("button");
|
||
button.type = "button"; button.dataset.mode = mode.id;
|
||
button.innerHTML = `<span>${mode.title}</span><code>${mode.strategy}</code>`;
|
||
button.addEventListener("click", () => selectMode(mode.id));
|
||
return button;
|
||
}));
|
||
const categories = ["全部", ...new Set(definitions.map(item => item.category))];
|
||
elements.filters.replaceChildren(...categories.map(category => {
|
||
const button = document.createElement("button");
|
||
button.type = "button"; button.textContent = category;
|
||
button.classList.toggle("active", category === activeCategory);
|
||
button.addEventListener("click", () => {
|
||
activeCategory = category;
|
||
[...elements.filters.children].forEach(child => child.classList.toggle("active", child === button));
|
||
applyCategory();
|
||
});
|
||
return button;
|
||
}));
|
||
}
|
||
|
||
function openMenu(card, x, y) {
|
||
if (!card.ready) { toast("该控件仍在等待后端描述", true); return; }
|
||
activeCard = card; activeTab = "controls";
|
||
elements.menuComponent.textContent = `${card.mode.strategy} / ${card.definition.component}`;
|
||
elements.menuTitle.textContent = card.definition.title;
|
||
elements.menuDescription.textContent = card.definition.description;
|
||
elements.menuStatus.textContent = "菜单仅包含当前控件和当前帧策略可调用的 API";
|
||
elements.menuTabs.forEach(button => button.classList.toggle("active", button.dataset.tab === activeTab));
|
||
renderMenuBody();
|
||
elements.menu.hidden = false;
|
||
const rect = elements.menu.getBoundingClientRect(), gap = 10;
|
||
elements.menu.style.left = `${Math.max(gap, Math.min(x, innerWidth - rect.width - gap))}px`;
|
||
elements.menu.style.top = `${Math.max(gap, Math.min(y, innerHeight - rect.height - gap))}px`;
|
||
}
|
||
function closeMenu() { elements.menu.hidden = true; activeCard = null; }
|
||
function renderControl(item) {
|
||
const card = activeCard;
|
||
const row = document.createElement("div"); row.className = "control-row";
|
||
const copy = document.createElement("div"); copy.className = "control-copy";
|
||
const label = document.createElement("label"); label.textContent = item.label;
|
||
const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api);
|
||
let input;
|
||
if (item.input === "select") {
|
||
input = document.createElement("select");
|
||
for (const value of item.options || []) { const option = document.createElement("option"); option.value = value; option.textContent = value; input.append(option); }
|
||
input.value = String(item.value);
|
||
} else {
|
||
input = document.createElement("input"); input.type = item.input === "boolean" ? "checkbox" : item.input;
|
||
if (item.input === "boolean") input.checked = Boolean(item.value); else input.value = item.value;
|
||
if (item.input === "number") { input.min = item.minimum; input.max = item.maximum; input.step = item.step || "any"; }
|
||
}
|
||
input.className = "control-input"; input.title = item.description || item.api;
|
||
const submit = value => {
|
||
card.send("gallery_patch", {patch: {[item.id]: value}});
|
||
elements.menuStatus.textContent = `提交 ${item.api} · 等待后端回读`;
|
||
};
|
||
if (item.input === "number") {
|
||
let committedValue = input.value;
|
||
const commit = () => {
|
||
const value = input.valueAsNumber;
|
||
const minimum = Number(item.minimum);
|
||
const maximum = Number(item.maximum);
|
||
if (input.value === "" || !Number.isFinite(value) ||
|
||
(Number.isFinite(minimum) && value < minimum) ||
|
||
(Number.isFinite(maximum) && value > maximum)) {
|
||
input.value = committedValue;
|
||
return;
|
||
}
|
||
if (value === Number(committedValue)) {
|
||
input.value = committedValue;
|
||
return;
|
||
}
|
||
committedValue = input.value;
|
||
item.value = value;
|
||
submit(value);
|
||
};
|
||
input.addEventListener("blur", commit);
|
||
input.addEventListener("keydown", event => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
commit();
|
||
} else if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
input.value = committedValue;
|
||
}
|
||
});
|
||
} else input.addEventListener("change", () => submit(item.input === "boolean" ? input.checked : input.value));
|
||
row.append(copy, input); return row;
|
||
}
|
||
function renderAction(item) {
|
||
const row = document.createElement("div"); row.className = "action-row";
|
||
const copy = document.createElement("div"); copy.className = "action-copy";
|
||
const label = document.createElement("strong"); label.textContent = item.label;
|
||
const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api);
|
||
const controls = document.createElement("div"); controls.className = "action-controls";
|
||
let argument = null;
|
||
if (item.argument_input) { argument = document.createElement("input"); argument.className = "control-input"; argument.type = item.argument_input; argument.value = item.argument_default; controls.append(argument); }
|
||
const button = document.createElement("button"); button.className = "action-button"; button.type = "button"; button.textContent = "执行";
|
||
button.addEventListener("click", () => {
|
||
const card = activeCard;
|
||
const payload = {action: item.id};
|
||
if (argument) payload.argument = item.argument_input === "number" ? Number(argument.value) : argument.value;
|
||
card.send("gallery_action", payload);
|
||
elements.menuStatus.textContent = `执行 ${item.api}`;
|
||
if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => card.requestFrame(performance.now(), true), 40);
|
||
});
|
||
controls.append(button); row.append(copy, controls); return row;
|
||
}
|
||
function renderGroups(items, renderer) {
|
||
const fragment = document.createDocumentFragment();
|
||
for (const [name, children] of grouped(items)) {
|
||
const section = document.createElement("section"); section.className = "control-group";
|
||
const title = document.createElement("h3"); title.textContent = name;
|
||
section.append(title, ...children.map(renderer)); fragment.append(section);
|
||
}
|
||
elements.menuBody.replaceChildren(fragment);
|
||
}
|
||
function renderData(value) {
|
||
const list = document.createElement("dl"); list.className = "telemetry-grid";
|
||
for (const [key, content] of flatten(value)) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = key; dd.textContent = content; list.append(dt, dd); }
|
||
elements.menuBody.replaceChildren(list);
|
||
}
|
||
function renderMenuBody() {
|
||
if (!activeCard) return;
|
||
if (activeTab === "actions") renderGroups(activeCard.actions, renderAction);
|
||
else if (activeTab === "observer") renderData(activeCard.telemetry.kernel_observer || {});
|
||
else if (activeTab === "performance") renderData({performance: activeCard.telemetry.performance || {}, client_performance: activeCard.telemetry.client_performance || {}, low_latency_limit: activeCard.telemetry.low_latency_limit || {current: "not_applicable"}, data_shape: activeCard.telemetry.data_shape || {}, overlay: {enabled: activeCard.telemetry.performance_overlay_enabled, lines: activeCard.telemetry.performance_overlay_lines}, frame_mode: activeCard.telemetry.frame_mode});
|
||
else renderGroups(activeCard.controls, renderControl);
|
||
}
|
||
|
||
function buildCatalog(data) {
|
||
definitions = [...(data.cases || [])].sort((a, b) => a.order - b.order);
|
||
modes = [...(data.frame_modes || [])].sort((a, b) => a.order - b.order);
|
||
if (!modes.length) throw new Error("后端没有返回帧策略目录");
|
||
elements.pages.replaceChildren();
|
||
elements.pageCount.textContent = data.coverage?.page_count ?? modes.length;
|
||
elements.caseCount.textContent = data.coverage?.case_count ?? definitions.length;
|
||
elements.canvasCount.textContent = data.coverage?.canvas_count ?? definitions.length * modes.length;
|
||
elements.apiCount.textContent = (data.coverage?.manual_control_count || 0) + (data.coverage?.manual_action_count || 0);
|
||
installNavigation();
|
||
if (!modes.some(mode => mode.id === activeMode)) activeMode = modes[0].id;
|
||
selectMode(activeMode);
|
||
setConnection("ready", "三种 Kernel 策略目录已加载");
|
||
}
|
||
function connectCatalog() {
|
||
const socket = new WebSocket(socketUrl);
|
||
socket.addEventListener("open", () => socket.send(message("gallery_catalog")));
|
||
socket.addEventListener("message", event => {
|
||
if (typeof event.data !== "string") return;
|
||
try { const data = JSON.parse(event.data); if (data.type === "catalog") { buildCatalog(data); socket.close(); } else if (data.type === "error") throw new Error(data.message); }
|
||
catch (error) { setConnection("error", "目录解析失败"); toast(error.message, true); }
|
||
});
|
||
socket.addEventListener("error", () => { setConnection("error", `无法连接 ${socketUrl} · 自动重连`); });
|
||
socket.addEventListener("close", () => {
|
||
if (!definitions.length) setTimeout(connectCatalog, 1000);
|
||
});
|
||
}
|
||
function loop(time) {
|
||
updateDisplayTiming(time);
|
||
const page = pages.get(activeMode);
|
||
for (const card of page?.cards || []) {
|
||
card.presentLatest(time);
|
||
card.updateMotionStatus(time);
|
||
if (card.mode.id !== "low_latency") card.requestFrame(time);
|
||
card.observe(time);
|
||
}
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
elements.streamToggle.addEventListener("click", () => {
|
||
streamsPaused = !streamsPaused;
|
||
elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流";
|
||
syncCardActivity();
|
||
});
|
||
elements.menuClose.addEventListener("click", closeMenu);
|
||
elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); }));
|
||
document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); });
|
||
document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); });
|
||
document.addEventListener("visibilitychange", () => {
|
||
lastAnimationFrameAt = 0;
|
||
displayIntervalMs = 0;
|
||
displayIntervalLatestMs = 0;
|
||
displayIntervalP95Ms = 0;
|
||
displayJitterMs = 0;
|
||
displayIntervalSamples.length = 0;
|
||
syncCardActivity();
|
||
});
|
||
window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.frameTimeout); clearTimeout(card.reconnectTimer); card.send("hide"); card.socket?.close(); } });
|
||
|
||
connectCatalog();
|
||
requestAnimationFrame(loop);
|