mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(director): add bounded scene data packs and placement
This commit is contained in:
1 parent
4436d7f18e
commit
92526e9a92
27 files changed
+1720
-28
No files matched your search
@@ -21,6 +21,7 @@ export function createApplicationTools({
|
||||
voice = {},
|
||||
startChrome,
|
||||
onSceneDirector,
|
||||
sceneDataPacks,
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
@@ -28,6 +29,7 @@ export function createApplicationTools({
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
dataPacks: sceneDataPacks,
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ test('version 4 retains anchor identity, references and explicit move edits thro
|
||||
project,
|
||||
);
|
||||
});
|
||||
test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4', () => {
|
||||
test('v3 unversioned bloom already uses scale 2 and is not migrated again in later formats', () => {
|
||||
const project = normalizeProject(
|
||||
parseSceneDocument(
|
||||
JSON.stringify({
|
||||
@@ -73,7 +73,7 @@ test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4'
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(project.version, 4);
|
||||
assert.equal(project.version, 5);
|
||||
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
|
||||
});
|
||||
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
uniqueId,
|
||||
jsonTree,
|
||||
} from './documentFields.js';
|
||||
import { validateSceneDataPacks } from './packs/manifest.js';
|
||||
import { validateSceneCameras } from './cameraDocument.js';
|
||||
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
|
||||
export const SCENE_DOCUMENT_VERSION = 4;
|
||||
export const SCENE_DOCUMENT_VERSION = 5;
|
||||
|
||||
function visual(value, path, legacy) {
|
||||
fields(value, path, [
|
||||
@@ -65,7 +66,7 @@ export function validateSceneDocument(project) {
|
||||
'scenes',
|
||||
]);
|
||||
const version = Object.hasOwn(project, 'version') ? project.version : 1;
|
||||
if (![1, 2, 3, 4].includes(version))
|
||||
if (![1, 2, 3, 4, 5].includes(version))
|
||||
fail('$.version', 'unsupported scene project version');
|
||||
const legacy = version < 3;
|
||||
for (const key of ['createdAt', 'updatedAt'])
|
||||
@@ -83,6 +84,7 @@ export function validateSceneDocument(project) {
|
||||
'appliedShotPacks',
|
||||
'shots',
|
||||
...(version >= 4 ? ['anchors'] : []),
|
||||
...(version >= 5 ? ['dataPacks'] : []),
|
||||
]);
|
||||
uniqueId(scene, path, sceneIds);
|
||||
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
|
||||
@@ -123,6 +125,7 @@ export function validateSceneDocument(project) {
|
||||
'sourcePackId',
|
||||
'sourcePackVersion',
|
||||
...(version >= 4 ? ['move'] : []),
|
||||
...(version >= 5 ? ['dataPackIds'] : []),
|
||||
]);
|
||||
uniqueId(shot, at, shotIds);
|
||||
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
|
||||
@@ -149,6 +152,7 @@ export function validateSceneDocument(project) {
|
||||
});
|
||||
});
|
||||
validateSceneCameras(scene, path, version);
|
||||
validateSceneDataPacks(scene, path);
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
|
||||
const migrated = normalizeProject(
|
||||
parseSceneDocument(JSON.stringify(project)),
|
||||
);
|
||||
assert.equal(migrated.version, 4);
|
||||
assert.equal(migrated.version, 5);
|
||||
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
|
||||
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PACK_LIMITS } from './manifest.js';
|
||||
|
||||
/** Decode a bounded geometry-only GeoJSON collection; properties never become HTML, styles or URLs. */
|
||||
export function decodePackGeoJSON(bytes) {
|
||||
const value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
);
|
||||
if (
|
||||
value?.type !== 'FeatureCollection' ||
|
||||
!Array.isArray(value.features) ||
|
||||
value.features.length > PACK_LIMITS.features
|
||||
)
|
||||
throw new Error('Expected a bounded FeatureCollection');
|
||||
let positions = 0;
|
||||
const ids = new Set();
|
||||
function position(p) {
|
||||
if (
|
||||
!Array.isArray(p) ||
|
||||
![2, 3].includes(p.length) ||
|
||||
p.some((v) => !Number.isFinite(v)) ||
|
||||
Math.abs(p[0]) > 180 ||
|
||||
Math.abs(p[1]) > 90 ||
|
||||
(p.length === 3 && (p[2] < -12000 || p[2] > 1e9)) ||
|
||||
++positions > PACK_LIMITS.positions
|
||||
)
|
||||
throw new Error('Invalid geographic position');
|
||||
return [p[0], p[1], p[2] ?? 0];
|
||||
}
|
||||
function line(points, ring = false) {
|
||||
if (!Array.isArray(points) || points.length < (ring ? 4 : 2))
|
||||
throw new Error('Invalid line');
|
||||
const normalized = points.map(position);
|
||||
if (ring && normalized[0].some((v, i) => v !== normalized.at(-1)[i]))
|
||||
throw new Error('Unclosed ring');
|
||||
return normalized;
|
||||
}
|
||||
return value.features.map((feature) => {
|
||||
const id = feature?.id;
|
||||
if (
|
||||
feature?.type !== 'Feature' ||
|
||||
typeof id !== 'string' ||
|
||||
!id.trim() ||
|
||||
id.length > 256 ||
|
||||
ids.has(id)
|
||||
)
|
||||
throw new Error('Features require distinct string IDs');
|
||||
ids.add(id);
|
||||
const g = feature.geometry;
|
||||
let coordinates;
|
||||
if (g?.type === 'Point') coordinates = position(g.coordinates);
|
||||
else if (g?.type === 'LineString') coordinates = line(g.coordinates);
|
||||
else if (
|
||||
g?.type === 'Polygon' &&
|
||||
Array.isArray(g.coordinates) &&
|
||||
g.coordinates.length &&
|
||||
g.coordinates.length <= 128
|
||||
)
|
||||
coordinates = g.coordinates.map((ring) => line(ring, true));
|
||||
else throw new Error('Unsupported geometry');
|
||||
return { id, type: g.type, coordinates };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
fields,
|
||||
string,
|
||||
number,
|
||||
array,
|
||||
optional,
|
||||
fail,
|
||||
} from '../documentFields.js';
|
||||
|
||||
export const PACK_LIMITS = Object.freeze({
|
||||
packs: 8,
|
||||
bytes: 8 * 1024 * 1024,
|
||||
totalBytes: 32 * 1024 * 1024,
|
||||
features: 2000,
|
||||
positions: 50000,
|
||||
});
|
||||
|
||||
/** Asset names are relative directory paths, never requests or executable modules. */
|
||||
export function validateAssetPath(value, path = 'source.path') {
|
||||
string(value, path, 1024);
|
||||
if (
|
||||
!value
|
||||
.split('/')
|
||||
.every((part) => /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(part))
|
||||
)
|
||||
fail(
|
||||
path,
|
||||
'expected a relative asset path without URL syntax or traversal',
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate one declarative pack; loading requires separately registered application adapters. */
|
||||
export function validateDataPack(pack, path, anchorIds) {
|
||||
fields(pack, path, [
|
||||
'id',
|
||||
'version',
|
||||
'format',
|
||||
'source',
|
||||
'attribution',
|
||||
'placement',
|
||||
'byteLength',
|
||||
'sha256',
|
||||
]);
|
||||
string(pack.id, `${path}.id`);
|
||||
if (pack.version !== 1) fail(`${path}.version`, 'unsupported pack version');
|
||||
if (!['geojson', 'image', 'media'].includes(pack.format))
|
||||
fail(`${path}.format`, 'unsupported pack format');
|
||||
fields(pack.source, `${path}.source`, ['adapter', 'path']);
|
||||
string(pack.source.adapter, `${path}.source.adapter`);
|
||||
validateAssetPath(pack.source.path, `${path}.source.path`);
|
||||
fields(pack.attribution, `${path}.attribution`, ['text', 'license', 'url']);
|
||||
string(pack.attribution.text, `${path}.attribution.text`, 4096);
|
||||
string(pack.attribution.license, `${path}.attribution.license`, 4096);
|
||||
optional(pack.attribution, 'url', `${path}.attribution`, (url, at) => {
|
||||
string(url, at, 2048);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
fail(at, 'expected an HTTPS source link');
|
||||
}
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
)
|
||||
fail(
|
||||
at,
|
||||
'expected an HTTPS source link without credentials, query or fragment',
|
||||
);
|
||||
});
|
||||
optional(pack, 'byteLength', path, (v, at) => {
|
||||
number(v, at, 1, PACK_LIMITS.bytes, false);
|
||||
if (!Number.isInteger(v)) fail(at, 'expected an integer');
|
||||
});
|
||||
optional(pack, 'sha256', path, (v, at) => {
|
||||
if (typeof v !== 'string' || !/^[a-f0-9]{64}$/.test(v))
|
||||
fail(at, 'expected a lowercase SHA-256 digest');
|
||||
});
|
||||
const p = pack.placement,
|
||||
at = `${path}.placement`;
|
||||
fields(
|
||||
p,
|
||||
at,
|
||||
pack.format === 'image'
|
||||
? ['bounds', 'height', 'altitudeReference']
|
||||
: pack.format === 'media'
|
||||
? ['anchorId']
|
||||
: ['altitudeReference'],
|
||||
);
|
||||
if (pack.format === 'media') {
|
||||
if (!anchorIds.has(p.anchorId))
|
||||
fail(`${at}.anchorId`, 'unknown scene anchor');
|
||||
} else {
|
||||
if (p.altitudeReference !== 'ellipsoid')
|
||||
fail(`${at}.altitudeReference`, 'expected ellipsoid height in meters');
|
||||
if (pack.format === 'image') {
|
||||
array(p.bounds, `${at}.bounds`, 4);
|
||||
if (p.bounds.length !== 4)
|
||||
fail(`${at}.bounds`, 'expected west, south, east, north');
|
||||
p.bounds.forEach((v, i) =>
|
||||
number(
|
||||
v,
|
||||
`${at}.bounds[${i}]`,
|
||||
i % 2 ? -90 : -180,
|
||||
i % 2 ? 90 : 180,
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (p.bounds[0] >= p.bounds[2] || p.bounds[1] >= p.bounds[3])
|
||||
fail(`${at}.bounds`, 'expected increasing non-dateline bounds');
|
||||
number(p.height, `${at}.height`, -12000, 1e9, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate scene-local pack IDs and shot references without acquiring assets. */
|
||||
export function validateSceneDataPacks(scene, path) {
|
||||
const packs = Object.hasOwn(scene, 'dataPacks') ? scene.dataPacks : [];
|
||||
array(packs, `${path}.dataPacks`, PACK_LIMITS.packs);
|
||||
const anchors = new Set((scene.anchors || []).map((a) => a.id));
|
||||
const seen = new Set();
|
||||
packs.forEach((pack, i) => {
|
||||
validateDataPack(pack, `${path}.dataPacks[${i}]`, anchors);
|
||||
if (seen.has(pack.id))
|
||||
fail(`${path}.dataPacks[${i}].id`, 'duplicate pack ID');
|
||||
seen.add(pack.id);
|
||||
});
|
||||
scene.shots.forEach((shot, i) => {
|
||||
optional(shot, 'dataPackIds', `${path}.shots[${i}]`, (ids, at) => {
|
||||
array(ids, at, PACK_LIMITS.packs);
|
||||
if (new Set(ids).size !== ids.length || ids.some((id) => !seen.has(id)))
|
||||
fail(at, 'expected distinct scene pack IDs');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
|
||||
import { normalizeProject } from '../../scenes/project.js';
|
||||
import { createDataPackSession } from './session.js';
|
||||
import { createAssetDirectorySource } from './source.js';
|
||||
import { decodePackGeoJSON } from './geojson.js';
|
||||
|
||||
const pack = () => ({
|
||||
id: 'outline',
|
||||
version: 1,
|
||||
format: 'geojson',
|
||||
source: { adapter: 'assets', path: 'example/outline.geojson' },
|
||||
attribution: { text: 'Example author', license: 'CC0-1.0' },
|
||||
placement: { altitudeReference: 'ellipsoid' },
|
||||
});
|
||||
const project = () => ({
|
||||
version: 5,
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene',
|
||||
dataPacks: [pack()],
|
||||
shots: [{ id: 'shot', dataPackIds: ['outline'] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const asset = () => ({
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
mimeType: 'application/json',
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
test('v5 packs and shot references round trip through project migration without acquiring bytes', () => {
|
||||
const normalized = normalizeProject(
|
||||
parseSceneDocument(JSON.stringify(project())),
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(stringifySceneDocument(normalized)).scenes[0].dataPacks,
|
||||
project().scenes[0].dataPacks,
|
||||
);
|
||||
assert.deepEqual(normalized.scenes[0].shots[0].dataPackIds, ['outline']);
|
||||
for (const version of [1, 2, 3, 4]) {
|
||||
const old = normalizeProject(
|
||||
parseSceneDocument(
|
||||
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
|
||||
),
|
||||
);
|
||||
assert.equal(old.version, 5);
|
||||
assert.equal(old.scenes[0].dataPacks, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest rejects duplicate/unknown IDs, unsupported placement and request or credential syntax', () => {
|
||||
const mutations = [
|
||||
(s) => {
|
||||
s.dataPacks = null;
|
||||
},
|
||||
(s) => s.dataPacks.push(pack()),
|
||||
(s) => s.shots[0].dataPackIds.push('missing'),
|
||||
(s) => s.shots[0].dataPackIds.push('outline'),
|
||||
(s) => (s.dataPacks[0].placement.altitudeReference = 'terrain'),
|
||||
(s) => (s.dataPacks[0].source.headers = { Authorization: 'secret' }),
|
||||
(s) => (s.dataPacks[0].attribution.url = 'javascript:alert(1)'),
|
||||
(s) => (s.dataPacks[0].byteLength = 0),
|
||||
(s) => (s.dataPacks[0].sha256 = 'wrong'),
|
||||
...[
|
||||
'../x',
|
||||
'/api/status',
|
||||
'https://example.com/x',
|
||||
'x?a=1',
|
||||
'x#key',
|
||||
'%2e%2e/x',
|
||||
'a\\b',
|
||||
'a//b',
|
||||
].map((path) => (s) => (s.dataPacks[0].source.path = path)),
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const p = project();
|
||||
mutate(p.scenes[0]);
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
|
||||
}
|
||||
const old = project();
|
||||
old.version = 4;
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(old)));
|
||||
});
|
||||
|
||||
test('image bounds and media anchor references are explicit and validated', () => {
|
||||
const p = project(),
|
||||
s = p.scenes[0],
|
||||
a = s.dataPacks[0];
|
||||
a.format = 'image';
|
||||
a.placement = {
|
||||
bounds: [-98, 30, -97, 31],
|
||||
height: 200,
|
||||
altitudeReference: 'ellipsoid',
|
||||
};
|
||||
assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p)));
|
||||
a.placement.bounds[2] = -99;
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
|
||||
a.format = 'media';
|
||||
a.placement = { anchorId: 'one' };
|
||||
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
|
||||
s.anchors = [
|
||||
{ id: 'one', lat: 30, lon: -97, alt: 200, altitudeReference: 'ellipsoid' },
|
||||
];
|
||||
assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p)));
|
||||
});
|
||||
|
||||
test('directory source confines paths, strips credentials and rejects redirects, oversized streaming bodies and missing assets', async () => {
|
||||
const requests = [];
|
||||
const source = createAssetDirectorySource({
|
||||
baseUrl: 'https://assets.example.org/packs/',
|
||||
fetchImpl: async (...args) => {
|
||||
requests.push(args);
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await source({ path: 'a/data.json' })).bytes,
|
||||
asset().bytes,
|
||||
);
|
||||
assert.equal(requests[0][0], 'https://assets.example.org/packs/a/data.json');
|
||||
assert.equal(requests[0][1].credentials, 'omit');
|
||||
assert.equal(requests[0][1].redirect, 'error');
|
||||
assert.equal(requests[0][1].referrerPolicy, 'no-referrer');
|
||||
await assert.rejects(source({ path: '../api/test' }));
|
||||
assert.equal(requests.length, 1);
|
||||
await assert.rejects(source({ path: 'a', maxBytes: 2 }), /byte limit/);
|
||||
const missing = createAssetDirectorySource({
|
||||
baseUrl: 'https://example.org/packs/',
|
||||
fetchImpl: async () => new Response('', { status: 404 }),
|
||||
});
|
||||
await assert.rejects(missing({ path: 'a' }), /unavailable/);
|
||||
});
|
||||
|
||||
test('GeoJSON preserves stable geometry IDs but never properties or remote style hints', () => {
|
||||
const data = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'p',
|
||||
properties: { title: '<script>', marker: 'https://bad.example/x' },
|
||||
geometry: { type: 'Point', coordinates: [-97, 30, 300] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const decode = () =>
|
||||
decodePackGeoJSON(new TextEncoder().encode(JSON.stringify(data)));
|
||||
assert.deepEqual(decode(), [
|
||||
{ id: 'p', type: 'Point', coordinates: [-97, 30, 300] },
|
||||
]);
|
||||
data.features.push(data.features[0]);
|
||||
assert.throws(decode, /IDs/);
|
||||
data.features.pop();
|
||||
data.features[0].geometry.coordinates[1] = 91;
|
||||
assert.throws(decode, /position/);
|
||||
data.features[0].geometry = {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
],
|
||||
],
|
||||
};
|
||||
assert.throws(decode, /ring/);
|
||||
});
|
||||
|
||||
test('pack session removes presentations and cancels the transport on Stop', async () => {
|
||||
let disposed = 0,
|
||||
signal;
|
||||
const session = createDataPackSession({
|
||||
sources: {
|
||||
assets: async (options) => {
|
||||
signal = options.signal;
|
||||
return asset();
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
geojson: async () => ({
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await session.load([pack()]);
|
||||
assert.deepEqual(session.getState(), { status: 'ready', count: 1 });
|
||||
session.clear();
|
||||
session.destroy();
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(session.getState().count, 0);
|
||||
});
|
||||
|
||||
test('replacing a pending source settles promptly and ignores its late bytes', async () => {
|
||||
const first = deferred();
|
||||
let mounted = 0,
|
||||
calls = 0;
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: () => (++calls === 1 ? first.promise : asset()) },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
mounted++;
|
||||
return {
|
||||
dispose() {
|
||||
mounted--;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const old = session.load([pack()]);
|
||||
await session.load([pack()]);
|
||||
assert.equal(await old, false);
|
||||
first.resolve(asset());
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(mounted, 1);
|
||||
session.destroy();
|
||||
assert.equal(mounted, 0);
|
||||
});
|
||||
|
||||
test('late renderer resources are disposed after cancellation without mutating a replacement', async () => {
|
||||
const rendering = deferred();
|
||||
let disposed = 0;
|
||||
const entered = deferred();
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: asset },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
entered.resolve();
|
||||
return rendering.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
const load = session.load([pack()]);
|
||||
await entered.promise;
|
||||
session.clear();
|
||||
assert.equal(await load, false);
|
||||
rendering.resolve({
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
|
||||
test('an abort between renderer settlement and continuation cannot leak the returned resource', async () => {
|
||||
const controller = new AbortController();
|
||||
let disposed = 0;
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: asset },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
queueMicrotask(() => controller.abort());
|
||||
return {
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await session.load([pack()], { signal: controller.signal }),
|
||||
false,
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(session.getState().count, 0);
|
||||
});
|
||||
|
||||
test('timeout settles an uncooperative adapter and failed packs roll back earlier resources', async () => {
|
||||
const session = createDataPackSession({
|
||||
timeoutMs: 10,
|
||||
sources: { assets: () => new Promise(() => {}) },
|
||||
adapters: { geojson: () => assert.fail() },
|
||||
});
|
||||
await assert.rejects(session.load([pack()]), /could not load/);
|
||||
assert.equal(session.getState().status, 'idle');
|
||||
let disposed = 0,
|
||||
called = 0;
|
||||
const rollback = createDataPackSession({
|
||||
sources: {
|
||||
assets: () => {
|
||||
if (++called === 2) throw new Error('secret URL');
|
||||
return asset();
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
geojson: () => ({
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
rollback.load([pack(), { ...pack(), id: 'second' }]),
|
||||
(e) => !e.message.includes('secret') && /could not load/.test(e.message),
|
||||
);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(rollback.getState().count, 0);
|
||||
});
|
||||
|
||||
test('byte and integrity checks run before rendering; adapter names never resolve inherited properties', async () => {
|
||||
let mounted = 0;
|
||||
const session = createDataPackSession({
|
||||
sources: { assets: asset },
|
||||
adapters: {
|
||||
geojson: () => {
|
||||
mounted++;
|
||||
return { dispose() {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const overrides of [
|
||||
{ byteLength: 2 },
|
||||
{ sha256: '0'.repeat(64) },
|
||||
{ source: { adapter: 'toString', path: 'a' } },
|
||||
])
|
||||
await assert.rejects(session.load([{ ...pack(), ...overrides }]));
|
||||
assert.equal(mounted, 0);
|
||||
const digest = await crypto.subtle.digest('SHA-256', asset().bytes);
|
||||
const sha256 = Buffer.from(digest).toString('hex');
|
||||
await session.load([{ ...pack(), byteLength: 3, sha256 }]);
|
||||
assert.equal(mounted, 1);
|
||||
session.destroy();
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { PACK_LIMITS, validateDataPack } from './manifest.js';
|
||||
|
||||
// Also settle adapters that fail to observe abort; dispose any late resource they return.
|
||||
function untilAbort(promise, signal, late = () => {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let ended = false;
|
||||
const abort = () => {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
reject(signal.reason);
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
Promise.resolve(promise).then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (ended) late(value);
|
||||
else {
|
||||
ended = true;
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Own bounded pack acquisition and disposable presentations for a single shot. */
|
||||
export function createDataPackSession({
|
||||
sources = {},
|
||||
adapters = {},
|
||||
timeoutMs = 15000,
|
||||
} = {}) {
|
||||
const sourceMap = new Map(Object.entries(sources));
|
||||
const adapterMap = new Map(Object.entries(adapters));
|
||||
let active = null,
|
||||
disposed = false;
|
||||
function clear() {
|
||||
const run = active;
|
||||
if (!run) return;
|
||||
active = null;
|
||||
run.controller.abort();
|
||||
run.detach();
|
||||
clearTimeout(run.timer);
|
||||
for (const handle of run.handles.splice(0).reverse()) handle.dispose();
|
||||
}
|
||||
return {
|
||||
clear,
|
||||
destroy() {
|
||||
disposed = true;
|
||||
clear();
|
||||
},
|
||||
getState() {
|
||||
return {
|
||||
status: active?.status || 'idle',
|
||||
count: active?.handles.length || 0,
|
||||
};
|
||||
},
|
||||
async load(packs, { anchors = [], signal } = {}) {
|
||||
clear();
|
||||
if (!Array.isArray(packs) || packs.length > PACK_LIMITS.packs)
|
||||
throw new Error('Too many data packs');
|
||||
const anchorIds = new Set(anchors.map((anchor) => anchor.id));
|
||||
packs.forEach((pack, i) =>
|
||||
validateDataPack(pack, `packs[${i}]`, anchorIds),
|
||||
);
|
||||
if (disposed || signal?.aborted) return false;
|
||||
if (!packs.length) return true;
|
||||
const controller = new AbortController();
|
||||
const run = {
|
||||
controller,
|
||||
handles: [],
|
||||
status: 'loading',
|
||||
detach: () => signal?.removeEventListener('abort', cancel),
|
||||
};
|
||||
const cancel = () => {
|
||||
if (active === run) clear();
|
||||
};
|
||||
active = run;
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
run.timer = setTimeout(
|
||||
() => controller.abort(new Error('Asset load timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
let total = 0;
|
||||
try {
|
||||
for (const pack of packs) {
|
||||
const source = sourceMap.get(pack.source.adapter),
|
||||
adapter = adapterMap.get(pack.format);
|
||||
if (!source || !adapter)
|
||||
throw new Error('Data pack adapter unavailable');
|
||||
const asset = await untilAbort(
|
||||
source({
|
||||
path: pack.source.path,
|
||||
signal: controller.signal,
|
||||
maxBytes: pack.byteLength || PACK_LIMITS.bytes,
|
||||
}),
|
||||
controller.signal,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
const { bytes } = asset;
|
||||
if (
|
||||
!(bytes instanceof Uint8Array) ||
|
||||
!bytes.length ||
|
||||
bytes.length > PACK_LIMITS.bytes ||
|
||||
(pack.byteLength && bytes.length !== pack.byteLength)
|
||||
)
|
||||
throw new Error('Asset byte length invalid');
|
||||
total += bytes.length;
|
||||
if (total > PACK_LIMITS.totalBytes)
|
||||
throw new Error('Shot assets exceed byte limit');
|
||||
if (pack.sha256) {
|
||||
const digest = await untilAbort(
|
||||
crypto.subtle.digest('SHA-256', bytes),
|
||||
controller.signal,
|
||||
);
|
||||
const hex = Array.from(new Uint8Array(digest), (b) =>
|
||||
b.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
if (hex !== pack.sha256)
|
||||
throw new Error('Asset integrity mismatch');
|
||||
}
|
||||
const handle = await untilAbort(
|
||||
adapter({ pack, asset, anchors, signal: controller.signal }),
|
||||
controller.signal,
|
||||
(late) => late?.dispose(),
|
||||
);
|
||||
if (!handle || typeof handle.dispose !== 'function')
|
||||
throw new Error('Data pack adapter requires disposal');
|
||||
if (active !== run || controller.signal.aborted) {
|
||||
handle.dispose();
|
||||
controller.signal.throwIfAborted();
|
||||
return false;
|
||||
}
|
||||
run.handles.push(handle);
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
clearTimeout(run.timer);
|
||||
run.status = 'ready';
|
||||
return true;
|
||||
} catch (error) {
|
||||
const superseded = active !== run || signal?.aborted || disposed;
|
||||
if (active === run) clear();
|
||||
if (superseded) return false;
|
||||
// Source errors may include URLs; only stable messages reach the UI.
|
||||
throw new Error(
|
||||
'Data pack could not load: check its source, format, size or integrity',
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PACK_LIMITS, validateAssetPath } from './manifest.js';
|
||||
|
||||
/** Register a fixed asset directory. Scene files cannot replace its origin or escape its path. */
|
||||
export function createAssetDirectorySource({
|
||||
baseUrl,
|
||||
fetchImpl = globalThis.fetch,
|
||||
}) {
|
||||
const base = new URL(baseUrl);
|
||||
if (
|
||||
!['https:', 'http:'].includes(base.protocol) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash ||
|
||||
!base.pathname.endsWith('/')
|
||||
)
|
||||
throw new TypeError(
|
||||
'Asset source requires an explicit HTTP(S) directory URL',
|
||||
);
|
||||
return async ({ path, signal, maxBytes = PACK_LIMITS.bytes }) => {
|
||||
validateAssetPath(path);
|
||||
const response = await fetchImpl(new URL(path, base).href, {
|
||||
signal,
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error('Asset unavailable');
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Asset stream unavailable');
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
try {
|
||||
if (Number(response.headers.get('content-length')) > maxBytes)
|
||||
throw new Error('Asset exceeds byte limit');
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
length += value.byteLength;
|
||||
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return {
|
||||
bytes,
|
||||
mimeType: (response.headers.get('content-type') || '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -118,3 +118,7 @@ export {
|
||||
resolveCameraMove,
|
||||
sampleCameraMove,
|
||||
} from './camera.js';
|
||||
|
||||
export { createAssetDirectorySource } from './packs/source.js';
|
||||
export { createDataPackSession } from './packs/session.js';
|
||||
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createDataPackSession } from '../../director/packs/session.js';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
|
||||
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
|
||||
const session = createDataPackSession({
|
||||
sources,
|
||||
adapters: createPackPresentations(viewer),
|
||||
});
|
||||
return {
|
||||
...session,
|
||||
apply(scene, shot, token) {
|
||||
const wanted = new Set(shot.dataPackIds || []);
|
||||
return session.load(
|
||||
(scene.dataPacks || []).filter((pack) => wanted.has(pack.id)),
|
||||
{ anchors: scene.anchors, signal: token?.signal },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
/** Render a bounded geometry pack in owned batches; no asynchronous entity geometry or update loop. */
|
||||
export function renderPackGeometry({ asset, pack }, handle) {
|
||||
if (!['application/json', 'application/geo+json'].includes(asset.mimeType))
|
||||
throw new Error('Expected GeoJSON');
|
||||
const features = decodePackGeoJSON(asset.bytes);
|
||||
const polygons = [];
|
||||
let lines;
|
||||
for (const feature of features) {
|
||||
const id = { packId: pack.id, featureId: feature.id };
|
||||
if (feature.type === 'Point')
|
||||
handle.add({
|
||||
name: `${pack.id} / ${feature.id}`,
|
||||
position: xyz(feature.coordinates),
|
||||
point: {
|
||||
pixelSize: 12,
|
||||
color: Cesium.Color.CYAN,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
});
|
||||
else if (feature.type === 'LineString') {
|
||||
if (!lines) {
|
||||
lines = new Cesium.PolylineCollection();
|
||||
handle.primitive(lines);
|
||||
}
|
||||
lines.add({
|
||||
id,
|
||||
positions: feature.coordinates.map(xyz),
|
||||
width: 4,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.CYAN,
|
||||
}),
|
||||
});
|
||||
} else
|
||||
polygons.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id,
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
feature.coordinates[0].map(xyz),
|
||||
feature.coordinates
|
||||
.slice(1)
|
||||
.map((ring) => new Cesium.PolygonHierarchy(ring.map(xyz))),
|
||||
),
|
||||
perPositionHeight: true,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.CYAN.withAlpha(0.35),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (polygons.length)
|
||||
handle.primitive(
|
||||
new Cesium.Primitive({
|
||||
asynchronous: false,
|
||||
geometryInstances: polygons,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
translucent: true,
|
||||
flat: true,
|
||||
closed: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderPackGeometry } from './geometry.js';
|
||||
|
||||
function caption(viewer, pack) {
|
||||
const card = document.createElement('section');
|
||||
card.dataset.directorPack = pack.id;
|
||||
Object.assign(card.style, {
|
||||
color: '#e3faff',
|
||||
background: '#08141eed',
|
||||
border: '1px solid #2491a8',
|
||||
padding: '8px',
|
||||
margin: '4px',
|
||||
maxWidth: '260px',
|
||||
font: '12px sans-serif',
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
|
||||
card.append(title);
|
||||
if (pack.attribution.url) {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = 'Source';
|
||||
link.href = pack.attribution.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.style.color = '#6eeaff';
|
||||
card.append(link);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
/** Create rendering adapters; data acquisition and authored placement stay independent. */
|
||||
export function createPackPresentations(viewer) {
|
||||
let panel;
|
||||
function addCard(pack) {
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.dataset.directorPacks = '';
|
||||
Object.assign(panel.style, {
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
bottom: '70px',
|
||||
maxHeight: '40vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: '30',
|
||||
});
|
||||
viewer.container.append(panel);
|
||||
}
|
||||
const card = caption(viewer, pack);
|
||||
panel.append(card);
|
||||
return card;
|
||||
}
|
||||
function resource(pack) {
|
||||
const entities = [],
|
||||
primitives = [],
|
||||
materials = [],
|
||||
urls = [];
|
||||
let card,
|
||||
destroyed = false;
|
||||
return {
|
||||
add(spec) {
|
||||
const entity = viewer.entities.add(spec);
|
||||
entities.push(entity);
|
||||
viewer.scene.requestRender();
|
||||
return entity;
|
||||
},
|
||||
primitive(value) {
|
||||
primitives.push(viewer.scene.primitives.add(value));
|
||||
if (value.appearance?.material)
|
||||
materials.push(value.appearance.material);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
card() {
|
||||
return (card ||= addCard(pack));
|
||||
},
|
||||
url(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
urls.push(url);
|
||||
return url;
|
||||
},
|
||||
dispose() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const media of card?.querySelectorAll('video,audio') || []) {
|
||||
media.pause();
|
||||
media.removeAttribute('src');
|
||||
media.load();
|
||||
}
|
||||
card?.remove();
|
||||
if (panel && !panel.childElementCount) {
|
||||
panel.remove();
|
||||
panel = null;
|
||||
}
|
||||
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
|
||||
for (const primitive of primitives.splice(0))
|
||||
viewer.scene.primitives.remove(primitive);
|
||||
for (const material of materials.splice(0)) {
|
||||
if (!material.isDestroyed()) material.destroy();
|
||||
}
|
||||
for (const url of urls.splice(0)) URL.revokeObjectURL(url);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
};
|
||||
}
|
||||
function guarded(render) {
|
||||
return async (context) => {
|
||||
const handle = resource(context.pack);
|
||||
const abort = () => handle.dispose();
|
||||
context.signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
context.signal.throwIfAborted();
|
||||
await render(context, handle);
|
||||
context.signal.throwIfAborted();
|
||||
handle.card();
|
||||
return handle;
|
||||
} catch (error) {
|
||||
handle.dispose();
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal.removeEventListener('abort', abort);
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
geojson: guarded(renderPackGeometry),
|
||||
image: guarded(async ({ asset, pack, signal }, handle) => {
|
||||
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
|
||||
const bytes = asset.bytes;
|
||||
if (
|
||||
bytes.length < 24 ||
|
||||
![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].every(
|
||||
(v, i) => bytes[i] === v,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid PNG header');
|
||||
const header = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength,
|
||||
);
|
||||
const width = header.getUint32(16),
|
||||
height = header.getUint32(20);
|
||||
if (!width || !height || width > 4096 || height > 4096)
|
||||
throw new Error('Image dimensions exceed limit');
|
||||
const image = new Image();
|
||||
image.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
|
||||
const abort = () => {
|
||||
image.src = '';
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
await image.decode();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
|
||||
throw new Error('Image dimensions exceed limit');
|
||||
handle.primitive(
|
||||
new Cesium.Primitive({
|
||||
asynchronous: false,
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
id: { packId: pack.id },
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds),
|
||||
height: pack.placement.height,
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material: Cesium.Material.fromType('Image', { image }),
|
||||
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
|
||||
flat: true,
|
||||
faceForward: true,
|
||||
translucent: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
media: guarded(({ asset, pack, anchors }, handle) => {
|
||||
if (
|
||||
![
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
].includes(asset.mimeType)
|
||||
)
|
||||
throw new Error('Unsupported media');
|
||||
const anchor = anchors.find(
|
||||
(item) => item.id === pack.placement.anchorId,
|
||||
);
|
||||
if (!anchor) throw new Error('Missing media anchor');
|
||||
handle.add({
|
||||
name: pack.id,
|
||||
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
|
||||
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
|
||||
label: {
|
||||
text: pack.id,
|
||||
font: '14px sans-serif',
|
||||
pixelOffset: new Cesium.Cartesian2(0, -24),
|
||||
},
|
||||
});
|
||||
const media = document.createElement(
|
||||
asset.mimeType.startsWith('video/') ? 'video' : 'audio',
|
||||
);
|
||||
media.controls = true;
|
||||
media.preload = 'none';
|
||||
media.style.width = '240px';
|
||||
media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
|
||||
handle.card().append(media);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
test('abort releases an in-flight image URL before the decoder settles or the viewer is destroyed', async () => {
|
||||
let settle;
|
||||
const pending = new Promise((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const priorImage = globalThis.Image;
|
||||
const priorRevoke = URL.revokeObjectURL;
|
||||
const revoked = [];
|
||||
globalThis.Image = class {
|
||||
decode() {
|
||||
return pending;
|
||||
}
|
||||
};
|
||||
URL.revokeObjectURL = (url) => {
|
||||
revoked.push(url);
|
||||
priorRevoke(url);
|
||||
};
|
||||
const viewer = {
|
||||
scene: { requestRender() {} },
|
||||
entities: {
|
||||
remove() {
|
||||
assert.fail('No entity should be acquired');
|
||||
},
|
||||
},
|
||||
};
|
||||
const signalOwner = new AbortController();
|
||||
const bytes = new Uint8Array(24);
|
||||
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
|
||||
const header = new DataView(bytes.buffer);
|
||||
header.setUint32(16, 8);
|
||||
header.setUint32(20, 8);
|
||||
try {
|
||||
const rendering = createPackPresentations(viewer).image({
|
||||
pack: {},
|
||||
asset: { bytes, mimeType: 'image/png' },
|
||||
signal: signalOwner.signal,
|
||||
});
|
||||
const rejected = assert.rejects(rendering, /abort/i);
|
||||
signalOwner.abort();
|
||||
assert.equal(
|
||||
revoked.length,
|
||||
1,
|
||||
'object URL must be released synchronously with cancellation',
|
||||
);
|
||||
settle();
|
||||
await rejected;
|
||||
assert.equal(revoked.length, 1, 'late decode cannot dispose twice');
|
||||
} finally {
|
||||
settle();
|
||||
globalThis.Image = priorImage;
|
||||
URL.revokeObjectURL = priorRevoke;
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createDefaultScenePacks } from './packs/defaults.js';
|
||||
import {
|
||||
layerStatesForShot,
|
||||
@@ -77,6 +78,7 @@ export class SceneDirector {
|
||||
{
|
||||
isMapStackAvailable = () => false,
|
||||
scenePacks = createDefaultScenePacks(),
|
||||
dataPacks = {},
|
||||
} = {},
|
||||
) {
|
||||
this._destroyed = false;
|
||||
@@ -86,6 +88,7 @@ export class SceneDirector {
|
||||
this.dataManager = dataManager;
|
||||
this._isMapStackAvailable = isMapStackAvailable;
|
||||
this._scenePacks = scenePacks;
|
||||
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
|
||||
this._cameraMotion = createCameraMotion({
|
||||
applyPose: (pose) => this._setCameraView(pose),
|
||||
});
|
||||
@@ -200,6 +203,7 @@ export class SceneDirector {
|
||||
this._cameraHandoffUnsubscribe?.();
|
||||
this._removeCameraInput?.();
|
||||
this._cameraMotion?.destroy();
|
||||
this._dataPacks?.destroy();
|
||||
this._controls?.destroy();
|
||||
this._state.destroy();
|
||||
this._destroyPromise = Promise.resolve().then(async () => {
|
||||
@@ -997,6 +1001,7 @@ export class SceneDirector {
|
||||
// manager) rather than merely ignored once it has already committed.
|
||||
this._cancelActiveSceneTravel();
|
||||
this._usesAuthoredCamera = !!shot.move;
|
||||
this._dataPacks?.clear();
|
||||
this._loadAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this._loadAbort = controller;
|
||||
@@ -1041,6 +1046,9 @@ export class SceneDirector {
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
return { started: false, reason: 'layers-refused' };
|
||||
}
|
||||
if (!(await this._applyDataPacks(scene, shot, token)))
|
||||
return { started: false, reason: 'data-packs-unavailable' };
|
||||
if (token.cancelled) return;
|
||||
if (seekState) {
|
||||
this._setCameraView(
|
||||
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||
@@ -1613,6 +1621,7 @@ export class SceneDirector {
|
||||
// Aborting cancels a layer transition already in flight; bumping the
|
||||
// generation disowns everything the load has not yet started.
|
||||
this._cancelActiveSceneTravel();
|
||||
this._dataPacks?.clear();
|
||||
this._loadAbort?.abort();
|
||||
this._loadAbort = null;
|
||||
this._loadGeneration++;
|
||||
@@ -1723,6 +1732,7 @@ export class SceneDirector {
|
||||
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
|
||||
*/
|
||||
stopScene(reason = 'Stopped') {
|
||||
this._dataPacks?.clear();
|
||||
this._sceneSeekGeneration++;
|
||||
this._loadAbort?.abort();
|
||||
this._loadAbort = null;
|
||||
@@ -1738,6 +1748,21 @@ export class SceneDirector {
|
||||
this._logEvent('scene_stopped', { reason });
|
||||
}
|
||||
|
||||
/** Apply only the shot's declared packs through registered sources. */
|
||||
async _applyDataPacks(scene, shot, token) {
|
||||
try {
|
||||
return (await this._dataPacks?.apply(scene, shot, token)) ?? true;
|
||||
} catch (error) {
|
||||
if (!token?.cancelled) this._updateStatus(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copied resource state for lifecycle checks and diagnostics. */
|
||||
getDataPackState() {
|
||||
return this._dataPacks?.getState() || { status: 'idle', count: 0 };
|
||||
}
|
||||
|
||||
/** Export the entire project as a timestamped JSON file download. */
|
||||
exportProject() {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
@@ -1912,6 +1937,7 @@ export class SceneDirector {
|
||||
* @returns {Promise<boolean>} True only when every owned layer is released
|
||||
*/
|
||||
async _releaseSceneLayers(scene, token = null) {
|
||||
this._dataPacks?.clear();
|
||||
const layerIds = Array.isArray(scene?.releaseLayerIds)
|
||||
? scene.releaseLayerIds
|
||||
: [];
|
||||
|
||||
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
||||
assert.equal(director._storageReadError, null);
|
||||
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 4);
|
||||
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 5);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -29,11 +29,18 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
{ isCurrent: () => !token.cancelled && !token.signal?.aborted },
|
||||
);
|
||||
},
|
||||
applyLayers({ scene, shot, token }) {
|
||||
return director._applyLayerStates(
|
||||
async applyLayers({ scene, shot, token }) {
|
||||
const result = await director._applyLayerStates(
|
||||
director._layerStatesForShot(scene, shot),
|
||||
token,
|
||||
);
|
||||
if (
|
||||
!token.cancelled &&
|
||||
!result.refused.length &&
|
||||
!(await director._applyDataPacks(scene, shot, token))
|
||||
)
|
||||
throw new Error('Data packs unavailable');
|
||||
return result;
|
||||
},
|
||||
travel({ scene, shot, token }) {
|
||||
director._loadedSceneId = scene.id;
|
||||
|
||||
@@ -253,6 +253,7 @@ export function normalizeShot(
|
||||
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
|
||||
: {}),
|
||||
},
|
||||
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
|
||||
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
@@ -346,6 +347,7 @@ export function normalizeProject(rawProject) {
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
|
||||
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { createAssetDirectorySource } from '../director/packs/source.js';
|
||||
import { createApplicationTools } from '../app/tools.js';
|
||||
import { startStandaloneChrome } from './startupChrome.js';
|
||||
export function createStandaloneTools(options) {
|
||||
return createApplicationTools({
|
||||
startChrome: startStandaloneChrome,
|
||||
sceneDataPacks: {
|
||||
sources: {
|
||||
assets: createAssetDirectorySource({
|
||||
baseUrl: new URL('/scene-assets/', window.location.href).href,
|
||||
}),
|
||||
},
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user