939 lines
50 KiB
JavaScript
939 lines
50 KiB
JavaScript
"use strict";
|
||
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 amisEmbed = window.amisRequire("amis/embed");
|
||
const amisLib = window.amisRequire("amis");
|
||
const React = window.amisRequire("react");
|
||
const sessions = new Map();
|
||
const rootNode = document.getElementById("root");
|
||
const menuHost = document.getElementById("menu-host");
|
||
let catalog = null;
|
||
let definitions = [];
|
||
let modes = [];
|
||
let mainScoped = null;
|
||
let menuScoped = null;
|
||
let menuSession = null;
|
||
let selectedCategory = "全部";
|
||
let streamsPaused = false;
|
||
let connectionState = "connecting";
|
||
let connectionText = "读取后端目录";
|
||
let noticeText = "";
|
||
let noticeError = false;
|
||
let noticeTimer = 0;
|
||
let menuStatus = "字段、验证与 API 映射来自后端 JSON";
|
||
let lastAnimationFrameAt = 0;
|
||
let displayIntervalMs = 0;
|
||
let displayIntervalLatestMs = 0;
|
||
let displayIntervalP95Ms = 0;
|
||
let displayJitterMs = 0;
|
||
let lastUiUpdateAt = 0;
|
||
const displayIntervalSamples = [];
|
||
const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload});
|
||
const quote = value => JSON.stringify(String(value));
|
||
const escapeHtml = value => String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||
const expression = path => `\${${path}}`;
|
||
const groupItems = 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 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 formatInteger(value) {
|
||
return Number(value || 0).toLocaleString();
|
||
}
|
||
function formatFps(value, digits = 1) {
|
||
return Number(value || 0).toFixed(digits);
|
||
}
|
||
function normalizeEventValue(payload, name) {
|
||
if (payload !== null && typeof payload === "object") {
|
||
if (Object.prototype.hasOwnProperty.call(payload, "value")) return payload.value;
|
||
if (Object.prototype.hasOwnProperty.call(payload, name)) return payload[name];
|
||
if (payload.data !== null && typeof payload.data === "object") return normalizeEventValue(payload.data, name);
|
||
}
|
||
return payload;
|
||
}
|
||
function notify(text, error = false) {
|
||
clearTimeout(noticeTimer);
|
||
noticeText = String(text || "");
|
||
noticeError = Boolean(error);
|
||
updateMainData();
|
||
noticeTimer = setTimeout(() => {
|
||
noticeText = "";
|
||
updateMainData();
|
||
}, 2600);
|
||
}
|
||
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);
|
||
}
|
||
const observerMetricDefinitions = [
|
||
["configured_frequency_hz", "配置频率"], ["observation_count", "观察次数"], ["latest_sequence", "最新序号"], ["produced_frame_count", "发布"],
|
||
["consumed_frame_count", "完成"], ["pending_frame_count", "待处理"], ["dropped_frame_count", "丢弃"], ["failed_operation_count", "失败"],
|
||
["target_interval_ns", "目标间隔"], ["paint_duration_ns", "PaintEvent"], ["render_duration_ns", "后台渲染"], ["bottleneck_duration_ns", "内部瓶颈"],
|
||
["consumer_feedback_master_enabled", "消费者总开关"], ["consumer_feedback_enabled", "Kernel 反馈有效"], ["consumer_pixel_feedback_enabled", "像素响应反馈"], ["consumer_presentation_feedback_enabled", "浏览器呈现反馈"],
|
||
["consumer_manual_feedback_enabled", "手动消费者反馈"], ["consumer_manual_fps", "手动消费者 FPS"], ["consumer_feedback_source", "生效反馈来源"], ["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", "消费者限速周期"], ["consumer_effective_fps", "消费者限速 FPS"],
|
||
["next_refresh_interval_ns", "下次刷新"], ["paint_lease_wait_ns", "Paint lease 等待"], ["paint_state_wait_ns", "Paint state 等待"], ["publish_state_wait_ns", "Publish state 等待"],
|
||
["ready_wait_ns", "Ready 等待"], ["frame_age_at_render_ns", "渲染时帧龄"], ["render_lease_wait_ns", "Render lease 等待"], ["render_state_wait_ns", "Render state 等待"],
|
||
["render_finish_state_wait_ns", "Render finish 等待"], ["queue_wait_ns", "Queue 等待"], ["end_to_end_ns", "端到端延迟"], ["last_event", "观察事件"]
|
||
];
|
||
const clientMetricDefinitions = [
|
||
["display_interval_latest_ms", "RAF 最新周期"], ["display_interval_ms", "RAF 中位周期"], ["display_interval_p95_ms", "RAF P95 周期"], ["display_jitter_ms", "RAF P95-P50 抖动"],
|
||
["frame_round_trip_ms", "WS 往返"], ["transport_fps", "像素响应 FPS"], ["presentation_fps", "浏览器呈现 FPS"], ["websocket_buffered_bytes", "WS 缓冲"],
|
||
["overwritten_pixel_frames", "未呈现覆盖"], ["changed_pixel_frames", "变化像素帧"], ["duplicate_pixel_frames", "重复像素帧"], ["frame_request_timeout_count", "像素请求超时"],
|
||
["last_pixel_receive_age_ms", "最近像素龄"], ["last_pixel_change_age_ms", "最近变化龄"]
|
||
];
|
||
const mainMetricDefinitions = [
|
||
["backendFps", "后端渲染 FPS"], ["transportFps", "像素响应 FPS"], ["presentationFps", "浏览器呈现 FPS"], ["roundTrip", "WS 往返 ms"], ["overwritten", "未呈现覆盖"],
|
||
["renderMs", "Core 渲染 ms"], ["encodeMs", "像素编码 ms"], ["bandwidth", "响应负载 MB/s"], ["pending", "Kernel 待处理"], ["dropped", "Kernel 丢弃"],
|
||
["points", "输入→绘制"], ["limit", "当前瓶颈"], ["event", "观察事件"], ["timeouts", "像素超时"], ["pixelAge", "最近像素龄"]
|
||
];
|
||
const limitDefinitions = [
|
||
["limitFrequency", "Kernel 用户频率"], ["limitPaint", "Kernel PaintEvent"], ["limitRender", "Kernel 后台渲染"], ["limitConsumer", "消费者反馈"]
|
||
];
|
||
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 booleanObserverFields = new Set([
|
||
"consumer_feedback_master_enabled", "consumer_feedback_enabled", "consumer_pixel_feedback_enabled", "consumer_presentation_feedback_enabled", "consumer_manual_feedback_enabled"
|
||
]);
|
||
function observerValue(name, observer) {
|
||
const raw = observer[name] ?? (name === "last_event" || name === "consumer_feedback_source" ? "none" : 0);
|
||
if (name === "consumer_effective_fps") {
|
||
const interval = Number(observer.consumer_interval_ns || 0);
|
||
return interval > 0 ? `${(1e9 / interval).toFixed(2)} FPS` : "0 FPS";
|
||
}
|
||
if (name === "consumer_manual_fps") return `${Number(raw || 0).toFixed(2)} FPS`;
|
||
if (name === "consumer_feedback_source") {
|
||
const names = {disabled: "总开关关闭", none: "无", pixel: "像素响应", presentation: "浏览器呈现", manual: "手动"};
|
||
return String(raw).split("+").map(value => names[value] || value).join(" + ");
|
||
}
|
||
if (booleanObserverFields.has(name)) return raw ? "启用" : "关闭";
|
||
if (nanosecondFields.has(name)) return formatNanoseconds(raw);
|
||
if (name === "configured_frequency_hz") return observer.frequency_limit_enabled === false ? "已关闭" : `${Number(raw || 0).toLocaleString()} Hz`;
|
||
return typeof raw === "number" ? raw.toLocaleString() : String(raw);
|
||
}
|
||
function clientValue(name, client) {
|
||
const raw = Number(client[name] || 0);
|
||
if (name.endsWith("_fps")) return `${raw.toFixed(2)} FPS`;
|
||
if (name.endsWith("_ms")) return `${raw.toFixed(3)} ms`;
|
||
if (name === "websocket_buffered_bytes") return `${raw.toLocaleString()} B`;
|
||
return raw.toLocaleString();
|
||
}
|
||
class GallerySession {
|
||
constructor(key, definition, mode) {
|
||
this.key = key;
|
||
this.definition = definition;
|
||
this.mode = mode;
|
||
this.controls = [];
|
||
this.actions = [];
|
||
this.telemetry = {};
|
||
this.controlValues = new Map();
|
||
this.lastSubmittedControls = new Map();
|
||
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.motionText = "等待动态帧";
|
||
this.socketState = "connecting";
|
||
this.socketText = "等待可见";
|
||
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.shell = null;
|
||
this.canvas = null;
|
||
this.context = null;
|
||
this.eventAbort = null;
|
||
this.resizeObserver = null;
|
||
this.intersectionObserver = null;
|
||
this.lastResizeWidth = 0;
|
||
this.lastResizeHeight = 0;
|
||
}
|
||
attach(shell, canvas) {
|
||
this.shell = shell;
|
||
this.canvas = canvas;
|
||
this.context = canvas.getContext("2d", {alpha: false});
|
||
this.eventAbort = new AbortController();
|
||
const options = {signal: this.eventAbort.signal};
|
||
const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)});
|
||
canvas.addEventListener("pointermove", event => pointer("pointer_move", event), options);
|
||
canvas.addEventListener("pointerdown", event => {
|
||
if (event.button === 2) return;
|
||
shell.focus();
|
||
canvas.setPointerCapture(event.pointerId);
|
||
pointer("pointer_press", event);
|
||
}, options);
|
||
canvas.addEventListener("pointerup", event => {
|
||
if (event.button !== 2) pointer("pointer_release", event);
|
||
}, options);
|
||
canvas.addEventListener("pointerleave", () => this.send("leave"), options);
|
||
canvas.addEventListener("contextmenu", event => {
|
||
event.preventDefault();
|
||
window.RenderiveGallery.openMenu(this.key);
|
||
}, options);
|
||
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, signal: this.eventAbort.signal});
|
||
shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)}), options);
|
||
shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)}), options);
|
||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||
this.resizeObserver.observe(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(shell);
|
||
this.syncActivity(true);
|
||
}
|
||
detach() {
|
||
this.eventAbort?.abort();
|
||
this.eventAbort = null;
|
||
this.resizeObserver?.disconnect();
|
||
this.resizeObserver = null;
|
||
this.intersectionObserver?.disconnect();
|
||
this.intersectionObserver = null;
|
||
this.intersecting = false;
|
||
this.lastResizeWidth = 0;
|
||
this.lastResizeHeight = 0;
|
||
this.shell = null;
|
||
this.canvas = null;
|
||
this.context = null;
|
||
if (this.ready && this.backendActive) this.send("hide");
|
||
this.backendActive = false;
|
||
}
|
||
displayVisible() {
|
||
return !document.hidden && this.intersecting && this.shell !== null;
|
||
}
|
||
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;
|
||
this.socketState = "connecting";
|
||
this.socketText = "连接中";
|
||
socket.binaryType = "arraybuffer";
|
||
socket.addEventListener("open", () => {
|
||
if (this.socket !== socket) return;
|
||
this.socketState = "ready";
|
||
this.socketText = "WS 已连接";
|
||
this.send("gallery_open", {case: this.definition.id, frame_mode: this.mode.id});
|
||
this.resize();
|
||
});
|
||
socket.addEventListener("message", event => {
|
||
if (this.socket !== socket) return;
|
||
if (typeof event.data === "string") this.receiveJson(event.data);
|
||
else this.receivePixels(event.data);
|
||
});
|
||
socket.addEventListener("close", () => {
|
||
if (this.socket !== socket) return;
|
||
this.ready = false;
|
||
this.shell?.classList.remove("rv-canvas-ready");
|
||
this.backendActive = null;
|
||
this.framePending = false;
|
||
clearTimeout(this.frameTimeout);
|
||
this.frameTimeout = null;
|
||
this.socketState = "error";
|
||
this.socketText = this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见";
|
||
this.updateMotionStatus();
|
||
if (!this.disposed && this.displayVisible()) this.reconnectTimer = setTimeout(() => this.connect(), 1000);
|
||
});
|
||
socket.addEventListener("error", () => {
|
||
if (this.socket !== socket) return;
|
||
this.socketState = "error";
|
||
this.socketText = "连接错误 · 自动重连";
|
||
});
|
||
}
|
||
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 {
|
||
notify(`${this.definition.title} 返回无效 JSON`, true);
|
||
return;
|
||
}
|
||
if (data.type === "error") {
|
||
const detail = Object.values(data.field_errors || {})[0] || data.message || "后端拒绝操作";
|
||
this.lastSubmittedControls.clear();
|
||
notify(detail, true);
|
||
if (menuSession === this) {
|
||
menuStatus = detail;
|
||
renderMenu(this, true);
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === "observer_state") {
|
||
this.telemetry = data.telemetry || {};
|
||
return;
|
||
}
|
||
if (data.type !== "case_state") return;
|
||
this.controls = data.controls?.data || [];
|
||
this.actions = data.actions?.data || [];
|
||
this.telemetry = data.telemetry || {};
|
||
this.controlValues.clear();
|
||
for (const item of this.controls) this.controlValues.set(item.id, item.value);
|
||
this.lastSubmittedControls.clear();
|
||
this.ready = true;
|
||
this.shell?.classList.add("rv-canvas-ready");
|
||
this.backendActive = null;
|
||
this.socketState = "ready";
|
||
this.socketText = `${data.frame_mode?.strategy || "Core2"} 在线`;
|
||
this.syncActivity();
|
||
if (data.notice && !data.notice.includes("已创建")) notify(`${this.definition.title}:${data.notice}`);
|
||
if (menuSession === this) {
|
||
menuStatus = data.notice || "后端状态已回读";
|
||
updateMenuData();
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
receivePixels(buffer) {
|
||
this.framePending = false;
|
||
if (this.frameTimeout !== null) {
|
||
clearTimeout(this.frameTimeout);
|
||
this.frameTimeout = null;
|
||
}
|
||
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) {
|
||
this.socketState = "error";
|
||
this.socketText = "像素帧无效 · 自动重连";
|
||
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);
|
||
const height = header.getUint32(8, true);
|
||
const stride = header.getUint32(12, true);
|
||
if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) {
|
||
this.socketState = "error";
|
||
this.socketText = "像素帧协议错误 · 自动重连";
|
||
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.socketState = "ready";
|
||
this.socketText = `${this.mode.strategy || "Core2"} 在线`;
|
||
this.transportFps = this.recordRate(this.transportTimes, now);
|
||
this.frameCount++;
|
||
this.updateMotionStatus(now);
|
||
this.requestFrame(now);
|
||
}
|
||
presentLatest(time) {
|
||
const buffer = this.latestPixelBuffer;
|
||
if (!buffer || !this.context || !this.canvas) return;
|
||
this.latestPixelBuffer = null;
|
||
const header = new DataView(buffer, 0, 16);
|
||
const width = header.getUint32(4, true);
|
||
const height = header.getUint32(8, true);
|
||
const 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);
|
||
const 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 || !this.shell) return;
|
||
const width = Math.max(240, Math.round(this.shell.clientWidth));
|
||
const height = Math.max(180, Math.round(this.shell.clientHeight));
|
||
if (width === this.lastResizeWidth && height === this.lastResizeHeight) return;
|
||
this.lastResizeWidth = width;
|
||
this.lastResizeHeight = height;
|
||
this.send("resize", {width, 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.socketState = "error";
|
||
this.socketText = "像素响应超时 · 正在恢复";
|
||
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: this.clientMetrics(time)});
|
||
}
|
||
clientMetrics(time = performance.now()) {
|
||
return {
|
||
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_latest_ms: displayIntervalLatestMs,
|
||
display_interval_ms: displayIntervalMs,
|
||
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
|
||
};
|
||
}
|
||
updateMotionStatus(now = performance.now()) {
|
||
let state = "waiting";
|
||
let 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 = "像素帧已停滞";
|
||
}
|
||
this.motionState = state;
|
||
this.motionText = label;
|
||
}
|
||
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);
|
||
}
|
||
commitControl(id, payload) {
|
||
const item = this.controls.find(control => control.id === id);
|
||
if (!item) return;
|
||
let value = normalizeEventValue(payload, id);
|
||
if (item.input === "number") value = Number(value);
|
||
else if (item.input === "boolean") value = Boolean(value);
|
||
else value = String(value ?? "");
|
||
if (item.input === "number" && (!Number.isFinite(value) || value < Number(item.minimum) || value > Number(item.maximum))) {
|
||
menuStatus = `${item.label} 超出后端允许范围`;
|
||
renderMenu(this, true);
|
||
return;
|
||
}
|
||
if (Object.is(this.lastSubmittedControls.get(id), value) || Object.is(this.controlValues.get(id), value)) return;
|
||
this.lastSubmittedControls.set(id, value);
|
||
this.send("gallery_patch", {patch: {[id]: value}});
|
||
menuStatus = `提交 ${item.api} · 等待后端回读`;
|
||
updateMenuData();
|
||
}
|
||
runAction(id, payload) {
|
||
const item = this.actions.find(action => action.id === id);
|
||
if (!item) return;
|
||
const request = {action: item.id};
|
||
if (item.argument_input) {
|
||
const field = `action_${item.id}`;
|
||
let value = normalizeEventValue(payload, field);
|
||
if (item.argument_input === "number") value = Number(value);
|
||
request.argument = value;
|
||
}
|
||
this.send("gallery_action", request);
|
||
menuStatus = `执行 ${item.api}`;
|
||
updateMenuData();
|
||
if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => this.requestFrame(performance.now(), true), 40);
|
||
}
|
||
controlData() {
|
||
const data = {};
|
||
for (const item of this.controls) data[item.id] = this.controlValues.get(item.id) ?? item.value;
|
||
for (const item of this.actions) if (item.argument_input) data[`action_${item.id}`] = item.argument_default;
|
||
return data;
|
||
}
|
||
snapshot() {
|
||
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"};
|
||
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 client = this.clientMetrics();
|
||
const observerValues = {};
|
||
const clientValues = {};
|
||
observerValues.mode = observer.mode || this.mode.id;
|
||
observerValues.limit_state = observer.limit_state || "not_applicable";
|
||
for (const [name] of observerMetricDefinitions) observerValues[name] = observerValue(name, observer);
|
||
for (const [name] of clientMetricDefinitions) clientValues[name] = clientValue(name, client);
|
||
const limitTile = (enabled, active, duration) => ({text: enabled ? `${active ? "当前瓶颈" : "未受限"} · ${formatNanoseconds(duration)}` : `已关闭 · ${formatNanoseconds(duration)}`, active: Boolean(active)});
|
||
return {
|
||
ready: this.ready,
|
||
socketState: this.socketState,
|
||
socketText: this.socketText,
|
||
motionState: this.motionState,
|
||
motionText: this.motionText,
|
||
frameCount: formatInteger(this.frameCount),
|
||
backendFps: formatFps(performanceData.measured_fps),
|
||
transportFps: formatFps(performanceData.pixel_response_fps || this.transportFps),
|
||
presentationFps: formatFps(this.presentationFps),
|
||
roundTrip: Number(this.frameRoundTripMs || 0).toFixed(2),
|
||
overwritten: formatInteger(this.overwrittenPixelFrames),
|
||
renderMs: Number(performanceData.last_render_ms || 0).toFixed(2),
|
||
encodeMs: Number(performanceData.last_pixel_encode_ms || 0).toFixed(2),
|
||
bandwidth: Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1),
|
||
pending: formatInteger(observer.pending_frame_count),
|
||
dropped: formatInteger(observer.dropped_frame_count),
|
||
points: `${formatInteger(dataShape.input_points)}→${formatInteger(dataShape.rendered_elements)}`,
|
||
limit: limitDuration === undefined ? limitName : `${limitName} · ${formatNanoseconds(limitDuration)}`,
|
||
event: observer.last_event || "none",
|
||
timeouts: formatInteger(this.frameTimeoutCount),
|
||
pixelAge: this.lastPixelReceivedAt ? `${Math.max(0, performance.now() - this.lastPixelReceivedAt).toFixed(0)} ms` : "—",
|
||
limitFrequency: limitTile(observer.frequency_limit_enabled !== false, limit.current === "frequency_limited", observer.target_interval_ns),
|
||
limitPaint: limitTile(true, limit.current === "paint_limited", observer.paint_duration_ns),
|
||
limitRender: limitTile(true, limit.current === "render_limited", observer.render_duration_ns),
|
||
limitConsumer: limitTile(Boolean(observer.consumer_feedback_enabled), limit.current === "consumer_limited", observer.consumer_interval_ns),
|
||
observer: observerValues,
|
||
client: clientValues,
|
||
rawTelemetry: this.telemetry
|
||
};
|
||
}
|
||
dispose() {
|
||
this.disposed = true;
|
||
clearTimeout(this.frameTimeout);
|
||
clearTimeout(this.reconnectTimer);
|
||
this.send("hide");
|
||
this.socket?.close();
|
||
this.detach();
|
||
}
|
||
}
|
||
function sessionKey(modeId, caseId) {
|
||
return `${modeId}_${caseId}`;
|
||
}
|
||
function ensureSession(key, definition, mode) {
|
||
if (!sessions.has(key)) sessions.set(key, new GallerySession(key, definition, mode));
|
||
return sessions.get(key);
|
||
}
|
||
function RenderiveCanvas(props) {
|
||
const shellRef = React.useRef(null);
|
||
const canvasRef = React.useRef(null);
|
||
const definition = definitions.find(item => item.id === props.caseId);
|
||
const mode = modes.find(item => item.id === props.modeId);
|
||
const session = ensureSession(props.cardKey, definition, mode);
|
||
React.useEffect(() => {
|
||
session.attach(shellRef.current, canvasRef.current);
|
||
return () => session.detach();
|
||
}, [session]);
|
||
return React.createElement("div", {ref: shellRef, className: `rv-canvas-shell ${session.ready ? "rv-canvas-ready" : ""}`, tabIndex: 0},
|
||
React.createElement("canvas", {ref: canvasRef, "aria-label": "Core2 后端像素画布"}),
|
||
React.createElement("div", {className: "rv-canvas-hint"}, "右键:本控件全部 API"),
|
||
React.createElement("div", {className: "rv-canvas-loading"}, React.createElement("small", null, "创建 Kernel Scene")));
|
||
}
|
||
amisLib.Renderer({test: /(^|\/)renderive-canvas$/})(RenderiveCanvas);
|
||
function metricGrid(key, definitionsList, className, prefix = "sessions") {
|
||
return {
|
||
type: "grid",
|
||
className,
|
||
columns: definitionsList.map(([field, label]) => ({xs: 6, sm: 4, md: 2, body: {type: "tpl", tpl: `<div class="rv-metric"><b>${expression(`${prefix}.${key}.${field}`)}</b><small>${label}</small></div>`}}))
|
||
};
|
||
}
|
||
function observerGrid(key, definitionList, source, className) {
|
||
return {
|
||
type: "grid",
|
||
className,
|
||
columns: definitionList.map(([field, label]) => ({xs: 6, sm: 4, md: 3, body: {type: "tpl", tpl: `<div class="rv-observer-metric"><b>${expression(`sessions.${key}.${source}.${field}`)}</b><small>${label}</small></div>`}}))
|
||
};
|
||
}
|
||
function buildCardSchema(definition, mode) {
|
||
const key = sessionKey(mode.id, definition.id);
|
||
ensureSession(key, definition, mode);
|
||
const category = String(definition.category).replace(/'/g, "\\'");
|
||
const frameLabel = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新";
|
||
const body = [
|
||
{type: "tpl", tpl: `<div class="rv-card-header"><div><span class="rv-card-category">${definition.category}</span><h3 class="rv-card-title">${definition.title}</h3></div><div class="rv-card-statuses"><span class="rv-live-status rv-live-status-${expression(`sessions.${key}.motionState`)}"><i></i>${expression(`sessions.${key}.motionText`)}</span><span class="rv-live-status rv-live-status-${expression(`sessions.${key}.socketState`)}"><i></i>${expression(`sessions.${key}.socketText`)}</span></div></div>`},
|
||
{type: "renderive-canvas", cardKey: key, caseId: definition.id, modeId: mode.id},
|
||
metricGrid(key, mainMetricDefinitions, "rv-metrics-grid")
|
||
];
|
||
if (mode.id === "low_latency") {
|
||
body.push({
|
||
type: "grid",
|
||
className: "rv-limit-grid",
|
||
columns: limitDefinitions.map(([field, label]) => ({xs: 12, sm: 6, body: {type: "tpl", tpl: `<div class="rv-limit-tile" data-active="${expression(`sessions.${key}.${field}.active`)}"><span>${label}</span><b>${expression(`sessions.${key}.${field}.text`)}</b></div>`}}))
|
||
});
|
||
body.push({type: "tpl", tpl: `<div class="rv-observer-title"><span>KERNEL <b>${expression(`sessions.${key}.observer.mode`)}</b> OBSERVER</span><span>事件 <b>${expression(`sessions.${key}.observer.last_event`)}</b></span></div>`});
|
||
body.push(observerGrid(key, observerMetricDefinitions, "observer", "rv-observer-grid"));
|
||
body.push(observerGrid(key, clientMetricDefinitions, "client", "rv-client-grid"));
|
||
}
|
||
body.push({type: "tpl", tpl: `<div class="rv-card-meta"><div><b>${definition.component}</b><small>Core2 组件</small></div><div><b>${definition.control_count_by_mode?.[mode.id] ?? "—"}</b><small>属性入口</small></div><div><b>${definition.action_count_by_mode?.[mode.id] ?? "—"}</b><small>动作入口</small></div><div><b>${expression(`sessions.${key}.frameCount`)}</b><small>像素帧</small></div></div>`});
|
||
return {
|
||
type: "panel",
|
||
className: "rv-card",
|
||
visibleOn: `\${selectedCategory === '全部' || selectedCategory === '${category}'}`,
|
||
body,
|
||
actions: [
|
||
{type: "button", label: frameLabel, level: "primary", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.requestFrame(${quote(key)});`}]}}},
|
||
{type: "button", label: "打开测试菜单", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.openMenu(${quote(key)});`}]}}}
|
||
]
|
||
};
|
||
}
|
||
function buildModeBody(mode) {
|
||
return {
|
||
type: "container",
|
||
body: [
|
||
{type: "tpl", tpl: `<div class="rv-page-header"><div><div class="rv-eyebrow">${mode.strategy}</div><h2>${mode.title} · 全控件页</h2></div><p>${mode.description}</p></div>`},
|
||
{type: "grid", className: "rv-gallery", columns: definitions.map(definition => ({xs: 12, lg: 6, body: buildCardSchema(definition, mode)}))}
|
||
]
|
||
};
|
||
}
|
||
function buildMainSchema() {
|
||
const categories = ["全部", ...new Set(definitions.map(item => item.category))];
|
||
return {
|
||
type: "page",
|
||
className: "rv-shell",
|
||
body: [
|
||
{type: "tpl", tpl: `<div class="rv-topbar"><div class="rv-brand"><span class="rv-brand-mark">R2</span><div><p class="rv-eyebrow">CORE2 · KERNEL · WEBSOCKET · AMIS</p><h1>全控件 API 与帧策略性能画廊</h1></div></div><div class="rv-top-actions"><span class="rv-connection rv-connection-${expression("connectionState")}"><i></i>${expression("connectionText")}</span></div></div>`},
|
||
{type: "flex", justify: "flex-end", className: "rv-top-actions", items: [{type: "button", label: "${streamsPaused ? '恢复自动像素流' : '暂停自动像素流'}", onEvent: {click: {actions: [{actionType: "custom", script: "window.RenderiveGallery.toggleStreams();"}]}}}]},
|
||
{type: "grid", className: "rv-hero", columns: [
|
||
{xs: 12, lg: 7, body: {type: "tpl", tpl: `<div><p class="rv-eyebrow">THREE REAL KERNEL STRATEGIES</p><h2>三套对称页面,同一批控件,直接比较</h2><p>标准 UI 由 AMIS SDK 渲染;实时二进制像素画布使用 React 自定义 Renderer。</p></div>`}},
|
||
{xs: 12, lg: 5, body: {type: "grid", className: "rv-hero-metrics", columns: [
|
||
{xs: 6, body: {type: "tpl", tpl: `<div class="rv-hero-metric"><b>${expression("pageCount")}</b><small>帧策略页</small></div>`}},
|
||
{xs: 6, body: {type: "tpl", tpl: `<div class="rv-hero-metric"><b>${expression("caseCount")}</b><small>每页控件</small></div>`}},
|
||
{xs: 6, body: {type: "tpl", tpl: `<div class="rv-hero-metric"><b>${expression("canvasCount")}</b><small>独立场景</small></div>`}},
|
||
{xs: 6, body: {type: "tpl", tpl: `<div class="rv-hero-metric"><b>${expression("apiCount")}</b><small>手测入口</small></div>`}}
|
||
]}}
|
||
]},
|
||
{type: "alert", level: "danger", body: "${noticeText}", visibleOn: "${noticeText && noticeError}", className: "rv-notice", showIcon: true},
|
||
{type: "alert", level: "info", body: "${noticeText}", visibleOn: "${noticeText && !noticeError}", className: "rv-notice", showIcon: true},
|
||
{type: "form", wrapWithPanel: false, className: "rv-filter-form", body: [{type: "button-group-select", name: "selectedCategory", options: categories.map(value => ({label: value, value})), onEvent: {change: {actions: [{actionType: "custom", script: "window.RenderiveGallery.setCategory(event.data);"}]}}}]},
|
||
{type: "tabs", className: "rv-mode-tabs", activeKey: modes.some(mode => mode.id === "low_latency") ? "low_latency" : modes[0]?.id, mountOnEnter: true, unmountOnExit: false, tabs: modes.map(mode => ({title: `${mode.title} · ${mode.strategy}`, key: mode.id, body: buildModeBody(mode)}))}
|
||
]
|
||
};
|
||
}
|
||
function controlEventAction(session, item, source) {
|
||
return {actionType: "custom", script: `window.RenderiveGallery.commitControl(${quote(session.key)},${quote(item.id)},${source});`};
|
||
}
|
||
function controlSchema(session, item) {
|
||
const base = {name: item.id, label: item.label, description: item.description || item.api, remark: item.api, size: "md"};
|
||
if (item.input === "boolean") {
|
||
return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "switch", onEvent: {change: {actions: [controlEventAction(session, item, "event.data")]}}}]};
|
||
}
|
||
if (item.input === "select") {
|
||
return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "select", options: (item.options || []).map(value => ({label: value, value})), onEvent: {change: {actions: [controlEventAction(session, item, "event.data")]}}}]};
|
||
}
|
||
if (item.input === "color") {
|
||
return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "input-color", onEvent: {change: {actions: [controlEventAction(session, item, "event.data")]}}}]};
|
||
}
|
||
if (item.input === "number") {
|
||
return {
|
||
type: "form",
|
||
wrapWithPanel: false,
|
||
className: "rv-control-form",
|
||
body: [{...base, type: "input-number", min: item.minimum, max: item.maximum, step: item.step || 1, precision: item.integer ? 0 : undefined, onEvent: {blur: {actions: [controlEventAction(session, item, "event.data")]}}}],
|
||
actions: [],
|
||
onEvent: {submit: {preventDefault: true, actions: [controlEventAction(session, item, "context.data")]}}
|
||
};
|
||
}
|
||
return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "input-text", onEvent: {blur: {actions: [controlEventAction(session, item, "event.data")]}}}]};
|
||
}
|
||
function buildControlTab(session) {
|
||
return [...groupItems(session.controls)].map(([group, items]) => ({type: "panel", className: "rv-control-panel", title: group, body: items.map(item => controlSchema(session, item))}));
|
||
}
|
||
function actionSchema(session, item) {
|
||
const field = `action_${item.id}`;
|
||
const label = item.label || item.id;
|
||
const api = item.api || item.id;
|
||
const description = item.description || "";
|
||
const body = [{type: "tpl", tpl: `<div class="rv-action-head"><div><b>${escapeHtml(label)}</b><code>${escapeHtml(api)}</code></div>${description ? `<small>${escapeHtml(description)}</small>` : ""}</div>`}];
|
||
if (item.argument_input) body.push({type: item.argument_input === "number" ? "input-number" : "input-text", name: field, label: item.argument_label || "参数"});
|
||
body.push({type: "button", label: `执行 · ${label}`, level: "primary", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.runAction(${quote(session.key)},${quote(item.id)},context.data);`}]}}});
|
||
return {type: "form", wrapWithPanel: false, className: "rv-action-form", body, actions: []};
|
||
}
|
||
function buildActionTab(session) {
|
||
return [...groupItems(session.actions)].map(([group, items]) => ({type: "panel", className: "rv-control-panel", title: group, body: items.map(item => actionSchema(session, item))}));
|
||
}
|
||
function menuObserverGrid(session) {
|
||
return {
|
||
type: "grid",
|
||
className: "rv-observer-grid",
|
||
columns: observerMetricDefinitions.map(([field, label]) => ({xs: 6, sm: 4, md: 3, body: {type: "tpl", tpl: `<div class="rv-observer-metric"><b>${expression(`observer.${field}`)}</b><small>${label}</small></div>`}}))
|
||
};
|
||
}
|
||
function buildMenuSchema(session) {
|
||
return {
|
||
type: "page",
|
||
className: "rv-menu-shell",
|
||
body: [
|
||
{type: "flex", className: "rv-menu-header", justify: "space-between", alignItems: "flex-start", items: [
|
||
{type: "tpl", tpl: `<div><p class="rv-eyebrow">${session.mode.strategy} / ${session.definition.component}</p><h2>${session.definition.title}</h2><p>${session.definition.description}</p></div>`},
|
||
{type: "button", label: "关闭", onEvent: {click: {actions: [{actionType: "custom", script: "window.RenderiveGallery.closeMenu();"}]}}}
|
||
]},
|
||
{type: "alert", level: "info", body: "${menuStatus}", showIcon: false},
|
||
{type: "tabs", className: "rv-menu-tabs", mountOnEnter: true, tabs: [
|
||
{title: "控件属性", body: buildControlTab(session)},
|
||
{title: "专属 API", body: buildActionTab(session)},
|
||
{title: "Kernel 观察者", body: menuObserverGrid(session)},
|
||
{title: "性能", body: {type: "json", className: "rv-json", source: "${rawTelemetry}", levelExpand: 2}}
|
||
]}
|
||
]
|
||
};
|
||
}
|
||
function rootData() {
|
||
const sessionData = {};
|
||
for (const [key, session] of sessions) sessionData[key] = session.snapshot();
|
||
const coverage = catalog?.coverage || {};
|
||
return {
|
||
connectionState,
|
||
connectionText,
|
||
streamsPaused,
|
||
selectedCategory,
|
||
noticeText,
|
||
noticeError,
|
||
pageCount: coverage.page_count ?? modes.length,
|
||
caseCount: coverage.case_count ?? definitions.length,
|
||
canvasCount: coverage.canvas_count ?? definitions.length * modes.length,
|
||
apiCount: (coverage.manual_control_count || 0) + (coverage.manual_action_count || 0),
|
||
sessions: sessionData
|
||
};
|
||
}
|
||
function updateMainData() {
|
||
if (mainScoped?.updateProps) mainScoped.updateProps({data: rootData()});
|
||
}
|
||
function menuData(session) {
|
||
const snapshot = session.snapshot();
|
||
return {...session.controlData(), menuStatus, observer: snapshot.observer, rawTelemetry: snapshot.rawTelemetry};
|
||
}
|
||
function updateMenuData() {
|
||
if (menuScoped?.updateProps && menuSession) menuScoped.updateProps({data: menuData(menuSession)});
|
||
}
|
||
function renderMenu(session, rebuild = false) {
|
||
if (!session.ready) {
|
||
notify("该控件仍在等待后端描述", true);
|
||
return;
|
||
}
|
||
menuSession = session;
|
||
menuHost.hidden = false;
|
||
if (rebuild) {
|
||
menuScoped?.unmount();
|
||
menuScoped = null;
|
||
}
|
||
if (!menuScoped) menuScoped = amisEmbed.embed("#menu-root", buildMenuSchema(session), {data: menuData(session)}, {theme: "cxd"});
|
||
else updateMenuData();
|
||
}
|
||
function closeMenu() {
|
||
menuScoped?.unmount();
|
||
menuScoped = null;
|
||
menuSession = null;
|
||
menuHost.hidden = true;
|
||
}
|
||
function buildCatalog(data) {
|
||
catalog = data;
|
||
definitions = [...(data.cases || [])].sort((left, right) => left.order - right.order);
|
||
modes = [...(data.frame_modes || [])].sort((left, right) => left.order - right.order);
|
||
if (!modes.length) throw new Error("后端没有返回帧策略目录");
|
||
for (const mode of modes) for (const definition of definitions) ensureSession(sessionKey(mode.id, definition.id), definition, mode);
|
||
connectionState = "ready";
|
||
connectionText = "三种 Kernel 策略目录已加载";
|
||
mainScoped?.unmount();
|
||
mainScoped = amisEmbed.embed(rootNode, buildMainSchema(), {data: rootData()}, {theme: "cxd"});
|
||
}
|
||
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) {
|
||
connectionState = "error";
|
||
connectionText = "目录解析失败";
|
||
notify(error instanceof Error ? error.message : String(error), true);
|
||
}
|
||
});
|
||
socket.addEventListener("error", () => {
|
||
connectionState = "error";
|
||
connectionText = `无法连接 ${socketUrl} · 自动重连`;
|
||
updateMainData();
|
||
});
|
||
socket.addEventListener("close", () => {
|
||
if (!catalog) setTimeout(connectCatalog, 1000);
|
||
});
|
||
}
|
||
function loop(time) {
|
||
updateDisplayTiming(time);
|
||
for (const session of sessions.values()) {
|
||
if (!session.shell) continue;
|
||
session.presentLatest(time);
|
||
session.updateMotionStatus(time);
|
||
if (session.mode.id !== "low_latency") session.requestFrame(time);
|
||
session.observe(time);
|
||
}
|
||
if (time - lastUiUpdateAt >= 200) {
|
||
lastUiUpdateAt = time;
|
||
updateMainData();
|
||
updateMenuData();
|
||
}
|
||
requestAnimationFrame(loop);
|
||
}
|
||
window.RenderiveGallery = {
|
||
setCategory(payload) {
|
||
const value = normalizeEventValue(payload, "selectedCategory");
|
||
selectedCategory = String(value || "全部");
|
||
updateMainData();
|
||
for (const session of sessions.values()) session.syncActivity();
|
||
},
|
||
toggleStreams() {
|
||
streamsPaused = !streamsPaused;
|
||
updateMainData();
|
||
for (const session of sessions.values()) session.syncActivity(true);
|
||
},
|
||
requestFrame(key) {
|
||
sessions.get(key)?.requestFrame(performance.now(), true);
|
||
},
|
||
openMenu(key) {
|
||
const session = sessions.get(key);
|
||
if (session) renderMenu(session, menuSession !== session);
|
||
},
|
||
closeMenu,
|
||
commitControl(key, id, payload) {
|
||
sessions.get(key)?.commitControl(id, payload);
|
||
},
|
||
runAction(key, id, payload) {
|
||
sessions.get(key)?.runAction(id, payload);
|
||
}
|
||
};
|
||
menuHost.addEventListener("pointerdown", event => {
|
||
if (event.target === menuHost) closeMenu();
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (event.key === "Escape" && !menuHost.hidden) closeMenu();
|
||
});
|
||
document.addEventListener("visibilitychange", () => {
|
||
lastAnimationFrameAt = 0;
|
||
displayIntervalMs = 0;
|
||
displayIntervalLatestMs = 0;
|
||
displayIntervalP95Ms = 0;
|
||
displayJitterMs = 0;
|
||
displayIntervalSamples.length = 0;
|
||
for (const session of sessions.values()) session.syncActivity();
|
||
});
|
||
window.addEventListener("beforeunload", () => {
|
||
for (const session of sessions.values()) session.dispose();
|
||
});
|
||
mainScoped = amisEmbed.embed(rootNode, {type: "page", className: "rv-shell", body: [{type: "tpl", tpl: "<div class=\"rv-hero\"><div><p class=\"rv-eyebrow\">RENDERIVE · AMIS SDK</p><h2>等待后端返回控件与帧策略目录</h2><p>正在连接 WebSocket catalog。</p></div></div>"}]}, {data: rootData()}, {theme: "cxd"});
|
||
connectCatalog();
|
||
requestAnimationFrame(loop);
|