Files
Renderive/webapp_gallery/app.js
T
2026-08-11 12:36:19 +08:00

939 lines
50 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 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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
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);