mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Clean up the panel after host testing
Drop the render restarts added while chasing the black overlays; keep the full render-error report and stop the timer after an error, as Cesium does. Correct comments the investigation outran, share the panel_request name, and document the host differences the panel works around.
This commit is contained in:
1 parent
d0da4b0722
commit
efdee8d255
6 files changed
+43
-53
No files matched your search
@@ -136,6 +136,15 @@ stylesheet, Cesium's script, and Cesium's workers combined into one script
|
|||||||
that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing
|
that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing
|
||||||
the app.
|
the app.
|
||||||
|
|
||||||
|
Hosts differ in ways the panel works around, all inside the panel only:
|
||||||
|
images and stylesheet files arrive as `data:` URLs, since some hosts refuse
|
||||||
|
`blob:` images; code that needs an https address for the app gets
|
||||||
|
`GEV_APP_BASE_URL`, since some hosts serve the page from their own scheme;
|
||||||
|
the globe keeps drawing from a timer when the host reports the panel hidden
|
||||||
|
and stops animation frames; and 2D canvases are kept in memory
|
||||||
|
(`willReadFrequently`), since a host that treats the panel as off screen may
|
||||||
|
drop their GPU contents and show the overlays as black over the globe.
|
||||||
|
|
||||||
Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and
|
Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and
|
||||||
an app-only tool with `ui: { visibility: ['app'] }`;
|
an app-only tool with `ui: { visibility: ['app'] }`;
|
||||||
`createMcpServer({ resources })` serves `resources/list` and
|
`createMcpServer({ resources })` serves `resources/list` and
|
||||||
|
|||||||
+3
-13
@@ -49,8 +49,6 @@ export function isEmbeddedInline() {
|
|||||||
// frames; see keepPanelRendering.
|
// frames; see keepPanelRendering.
|
||||||
const PANEL_FRAME_MS = 33;
|
const PANEL_FRAME_MS = 33;
|
||||||
const MISSED_FRAMES_MS = 250;
|
const MISSED_FRAMES_MS = 250;
|
||||||
const RENDER_RESTARTS = 3;
|
|
||||||
const RENDER_RESTART_MS = 1000;
|
|
||||||
|
|
||||||
/** A render error's details, including the plain objects workers report. */
|
/** A render error's details, including the plain objects workers report. */
|
||||||
function describeError(error) {
|
function describeError(error) {
|
||||||
@@ -80,11 +78,10 @@ export function keepPanelRendering(
|
|||||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||||
};
|
};
|
||||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||||
// Cesium stops drawing after a render error. Report it in full, and
|
// Cesium stops drawing after a render error, and so does the timer.
|
||||||
// start again a few times in case it does not repeat.
|
// Report the error in full: workers report plain objects, which Cesium
|
||||||
|
// prints as [object Object].
|
||||||
let failed = false;
|
let failed = false;
|
||||||
let restarts = 0;
|
|
||||||
let restart = null;
|
|
||||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||||
(_scene, error) => {
|
(_scene, error) => {
|
||||||
failed = true;
|
failed = true;
|
||||||
@@ -92,12 +89,6 @@ export function keepPanelRendering(
|
|||||||
"[God's Eye View panel] render error:",
|
"[God's Eye View panel] render error:",
|
||||||
describeError(error),
|
describeError(error),
|
||||||
);
|
);
|
||||||
if (restarts >= RENDER_RESTARTS) return;
|
|
||||||
restarts += 1;
|
|
||||||
restart = windowRef.setTimeout(() => {
|
|
||||||
failed = false;
|
|
||||||
viewer.useDefaultRenderLoop = true;
|
|
||||||
}, RENDER_RESTART_MS);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const timer = windowRef.setInterval(() => {
|
const timer = windowRef.setInterval(() => {
|
||||||
@@ -112,7 +103,6 @@ export function keepPanelRendering(
|
|||||||
}, PANEL_FRAME_MS);
|
}, PANEL_FRAME_MS);
|
||||||
return () => {
|
return () => {
|
||||||
windowRef.clearInterval(timer);
|
windowRef.clearInterval(timer);
|
||||||
windowRef.clearTimeout?.(restart);
|
|
||||||
windowRef.cancelAnimationFrame(frameRequest);
|
windowRef.cancelAnimationFrame(frameRequest);
|
||||||
removeErrorListener?.();
|
removeErrorListener?.();
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-16
@@ -247,19 +247,19 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
|
|||||||
assert.equal(frame, null);
|
assert.equal(frame, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a panel reports a render error in full and starts drawing again', () => {
|
test('a panel reports a render error in full and stops drawing', () => {
|
||||||
let listener = null;
|
let listener = null;
|
||||||
const timeouts = [];
|
let tick = null;
|
||||||
const windowRef = {
|
const windowRef = {
|
||||||
requestAnimationFrame: () => 1,
|
requestAnimationFrame: () => 1,
|
||||||
cancelAnimationFrame() {},
|
cancelAnimationFrame() {},
|
||||||
setInterval: () => 1,
|
setInterval: (fn) => ((tick = fn), 1),
|
||||||
clearInterval() {},
|
clearInterval() {},
|
||||||
setTimeout: (fn) => (timeouts.push(fn), timeouts.length),
|
|
||||||
clearTimeout() {},
|
|
||||||
};
|
};
|
||||||
|
const drawn = [];
|
||||||
const viewer = {
|
const viewer = {
|
||||||
useDefaultRenderLoop: false,
|
resize() {},
|
||||||
|
render: () => drawn.push('render'),
|
||||||
scene: {
|
scene: {
|
||||||
renderError: {
|
renderError: {
|
||||||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||||
@@ -270,17 +270,13 @@ test('a panel reports a render error in full and starts drawing again', () => {
|
|||||||
const original = console.error;
|
const original = console.error;
|
||||||
console.error = (...args) => logged.push(args.join(' '));
|
console.error = (...args) => logged.push(args.join(' '));
|
||||||
try {
|
try {
|
||||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => 0 });
|
let clock = 0;
|
||||||
for (let attempt = 0; attempt < 5; attempt++)
|
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||||
listener(viewer.scene, {
|
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||||
name: 'RuntimeError',
|
|
||||||
message: 'worker failed',
|
|
||||||
});
|
|
||||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||||
// Only a few restarts, so an error that repeats does not spin.
|
clock = 1000;
|
||||||
assert.equal(timeouts.length, 3);
|
tick();
|
||||||
timeouts[0]();
|
assert.deepEqual(drawn, []);
|
||||||
assert.equal(viewer.useDefaultRenderLoop, true);
|
|
||||||
stop();
|
stop();
|
||||||
assert.equal(listener, null);
|
assert.equal(listener, null);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function panelRuntime(config) {
|
|||||||
* A data: URL holding an app file, for elements that load by URL. Hosts
|
* A data: URL holding an app file, for elements that load by URL. Hosts
|
||||||
* allow data: images where some refuse blob: ones.
|
* allow data: images where some refuse blob: ones.
|
||||||
*/
|
*/
|
||||||
function appObjectUrl(path) {
|
function appFileUrl(path) {
|
||||||
if (!fileUrls.has(path))
|
if (!fileUrls.has(path))
|
||||||
fileUrls.set(
|
fileUrls.set(
|
||||||
path,
|
path,
|
||||||
@@ -293,7 +293,7 @@ export function panelRuntime(config) {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Images name app files by URL; give them the file as a blob: URL.
|
// Images name app files by URL; give them the file as a data: URL.
|
||||||
const image = Object.getOwnPropertyDescriptor(
|
const image = Object.getOwnPropertyDescriptor(
|
||||||
HTMLImageElement.prototype,
|
HTMLImageElement.prototype,
|
||||||
'src',
|
'src',
|
||||||
@@ -303,7 +303,7 @@ export function panelRuntime(config) {
|
|||||||
set(value) {
|
set(value) {
|
||||||
const path = appPath(value);
|
const path = appPath(value);
|
||||||
if (path === null) return image.set.call(this, value);
|
if (path === null) return image.set.call(this, value);
|
||||||
appObjectUrl(path).then(
|
appFileUrl(path).then(
|
||||||
(url) => image.set.call(this, url),
|
(url) => image.set.call(this, url),
|
||||||
() => this.dispatchEvent(new Event('error')),
|
() => this.dispatchEvent(new Event('error')),
|
||||||
);
|
);
|
||||||
@@ -365,7 +365,7 @@ export function panelRuntime(config) {
|
|||||||
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
|
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
|
||||||
const path = appPath(element.getAttribute('data-gev-src'));
|
const path = appPath(element.getAttribute('data-gev-src'));
|
||||||
element.removeAttribute('data-gev-src');
|
element.removeAttribute('data-gev-src');
|
||||||
appObjectUrl(path).then((url) => element.setAttribute('src', url));
|
appFileUrl(path).then((url) => element.setAttribute('src', url));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const innerHtml = Object.getOwnPropertyDescriptor(
|
const innerHtml = Object.getOwnPropertyDescriptor(
|
||||||
@@ -395,15 +395,13 @@ export function panelRuntime(config) {
|
|||||||
if (element.nodeName !== 'IMG') continue;
|
if (element.nodeName !== 'IMG') continue;
|
||||||
const path = appPath(element.getAttribute('src'));
|
const path = appPath(element.getAttribute('src'));
|
||||||
if (path !== null)
|
if (path !== null)
|
||||||
appObjectUrl(path).then((url) =>
|
appFileUrl(path).then((url) => element.setAttribute('src', url));
|
||||||
element.setAttribute('src', url),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace url() references to app files in CSS with blob: URLs. */
|
/** Replace url() references to app files in CSS with data: URLs. */
|
||||||
async function cssWithFiles(text, baseUrl) {
|
async function cssWithFiles(text, baseUrl) {
|
||||||
const found = new Map();
|
const found = new Map();
|
||||||
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
|
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
|
||||||
@@ -413,7 +411,7 @@ export function panelRuntime(config) {
|
|||||||
if (path !== null && !found.has(reference))
|
if (path !== null && !found.has(reference))
|
||||||
found.set(
|
found.set(
|
||||||
reference,
|
reference,
|
||||||
appObjectUrl(path).catch(() => null),
|
appFileUrl(path).catch(() => null),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let result = text;
|
let result = text;
|
||||||
@@ -447,13 +445,10 @@ export function panelRuntime(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runScript(text, type) {
|
function runScript(text, type) {
|
||||||
return new Promise((resolve) => {
|
const script = document.createElement('script');
|
||||||
const script = document.createElement('script');
|
if (type) script.type = type;
|
||||||
if (type) script.type = type;
|
script.textContent = text;
|
||||||
script.textContent = text;
|
document.body.appendChild(script);
|
||||||
document.body.appendChild(script);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -507,7 +502,7 @@ export function panelRuntime(config) {
|
|||||||
document.body.insertBefore(document.adoptNode(node), status);
|
document.body.insertBefore(document.adoptNode(node), status);
|
||||||
}
|
}
|
||||||
for (const [image, path] of images)
|
for (const [image, path] of images)
|
||||||
appObjectUrl(path).then((url) => image.setAttribute('src', url));
|
appFileUrl(path).then((url) => image.setAttribute('src', url));
|
||||||
const scripts = await Promise.all(
|
const scripts = await Promise.all(
|
||||||
[...built.querySelectorAll('script')].map(async (script) => ({
|
[...built.querySelectorAll('script')].map(async (script) => ({
|
||||||
type: script.type,
|
type: script.type,
|
||||||
@@ -516,9 +511,9 @@ export function panelRuntime(config) {
|
|||||||
: script.textContent,
|
: script.textContent,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
for (const { type, text } of scripts) await runScript(text, type);
|
for (const { type, text } of scripts) runScript(text, type);
|
||||||
// Browsers cap live 3D contexts across a conversation's panels and take
|
// Browsers cap live 3D contexts across pages and take one back when
|
||||||
// one back when there are too many; say so rather than show no map.
|
// there are too many; say so rather than show no map.
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'webglcontextlost',
|
'webglcontextlost',
|
||||||
() => {
|
() => {
|
||||||
@@ -594,8 +589,7 @@ export function panelRuntime(config) {
|
|||||||
showDisplayMode(message.params);
|
showDisplayMode(message.params);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fullscreen, where the host offers it: the globe gets the room, and some
|
// Fullscreen, where the host offers it, gives the globe the room.
|
||||||
// hosts only keep a panel on screen up to date outside the conversation.
|
|
||||||
let displayMode = 'inline';
|
let displayMode = 'inline';
|
||||||
const showDisplayMode = (context) => {
|
const showDisplayMode = (context) => {
|
||||||
if (context?.displayMode) displayMode = context.displayMode;
|
if (context?.displayMode) displayMode = context.displayMode;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const LOAD_TIMEOUT_MS = 90_000;
|
|||||||
*/
|
*/
|
||||||
const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/';
|
const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/';
|
||||||
/** The panel-only tool the panel loads the app through. */
|
/** The panel-only tool the panel loads the app through. */
|
||||||
const PANEL_REQUEST_TOOL = 'panel_request';
|
export const PANEL_REQUEST_TOOL = 'panel_request';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The panel page: its status line, the Open in God's Eye View button, and
|
* The panel page: its status line, the Open in God's Eye View button, and
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
|
import { PANEL_REQUEST_TOOL } from '../globePanel.js';
|
||||||
|
|
||||||
/** Bytes of response body per call, before base64. */
|
/** Bytes of response body per call, before base64. */
|
||||||
export const PANEL_PART_BYTES = 512 * 1024;
|
export const PANEL_PART_BYTES = 512 * 1024;
|
||||||
@@ -83,7 +84,7 @@ function checkedPath(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const panelRequest = defineTool({
|
export const panelRequest = defineTool({
|
||||||
name: 'panel_request',
|
name: PANEL_REQUEST_TOOL,
|
||||||
title: "God's Eye View panel request",
|
title: "God's Eye View panel request",
|
||||||
description:
|
description:
|
||||||
"Loads a file or data for the God's Eye View panel from the app's " +
|
"Loads a file or data for the God's Eye View panel from the app's " +
|
||||||
|
|||||||
Reference in new issue
Block a user