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