Files
Renderive/webapp_gallery/app.js
T
2026-08-11 16:10:02 +08:00

731 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);