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
@@ -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';
|
||||
Reference in new issue
Block a user