mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Add Director authoring, import previews and scene bundles
This commit is contained in:
1 parent
62f66c4b86
commit
078fc290ec
25 files changed
+1930
-48
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { parseSceneDocument, stringifySceneDocument } from './document.js';
|
||||
|
||||
/** Build a validated immutable edit while preserving IDs, existing content and provenance. */
|
||||
export function editSceneDetails(
|
||||
project,
|
||||
sceneId,
|
||||
shotId,
|
||||
sceneDetails,
|
||||
shotDetails,
|
||||
) {
|
||||
const copy = parseSceneDocument(stringifySceneDocument(project));
|
||||
const scene = copy.scenes.find((s) => s.id === sceneId),
|
||||
shot = scene?.shots.find((s) => s.id === shotId);
|
||||
if (!scene || !shot) throw new Error('Select a scene and shot first');
|
||||
for (const key of Object.keys(sceneDetails))
|
||||
if (!['anchors', 'dataPacks'].includes(key))
|
||||
throw new Error('Unsupported scene detail');
|
||||
for (const key of Object.keys(shotDetails))
|
||||
if (
|
||||
![
|
||||
'camera',
|
||||
'move',
|
||||
'durationSec',
|
||||
'holdSec',
|
||||
'dataPackIds',
|
||||
'interactions',
|
||||
].includes(key)
|
||||
)
|
||||
throw new Error('Unsupported shot detail');
|
||||
for (const key of ['anchors', 'dataPacks']) {
|
||||
delete scene[key];
|
||||
if (Object.hasOwn(sceneDetails, key)) scene[key] = sceneDetails[key];
|
||||
}
|
||||
for (const key of [
|
||||
'camera',
|
||||
'move',
|
||||
'durationSec',
|
||||
'holdSec',
|
||||
'dataPackIds',
|
||||
'interactions',
|
||||
]) {
|
||||
delete shot[key];
|
||||
if (Object.hasOwn(shotDetails, key)) shot[key] = shotDetails[key];
|
||||
}
|
||||
return parseSceneDocument(stringifySceneDocument(copy));
|
||||
}
|
||||
|
||||
/** Export exactly one authored scene; no live state or service configuration is consulted. */
|
||||
export function selectSceneDocument(project, sceneId) {
|
||||
const copy = parseSceneDocument(stringifySceneDocument(project));
|
||||
const scene = copy.scenes.find((s) => s.id === sceneId);
|
||||
if (!scene) throw new Error('Select a scene first');
|
||||
return { ...copy, scenes: [scene] };
|
||||
}
|
||||
@@ -124,3 +124,14 @@ export { createDataPackSession } from './packs/session.js';
|
||||
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
|
||||
|
||||
export { createInteractionSession } from './interactions/session.js';
|
||||
|
||||
export {
|
||||
parseSceneShare,
|
||||
readSceneShare,
|
||||
createSceneBundle,
|
||||
createBundleAssets,
|
||||
BUNDLE_SOURCE,
|
||||
SHARE_LIMITS,
|
||||
} from './sharing/bundle.js';
|
||||
export { describeSceneShare } from './sharing/preview.js';
|
||||
export { editSceneDetails, selectSceneDocument } from './authoring.js';
|
||||
@@ -0,0 +1,221 @@
|
||||
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
|
||||
import { withShareSignal } from './lifetime.js';
|
||||
import { fields, array, fail } from '../documentFields.js';
|
||||
import { PACK_LIMITS, validateAssetPath } from '../packs/manifest.js';
|
||||
|
||||
export const BUNDLE_SOURCE = 'scene-bundle';
|
||||
export const SHARE_LIMITS = Object.freeze({
|
||||
bytes: 50 * 1024 * 1024,
|
||||
assets: 64,
|
||||
});
|
||||
const MIME = new Set([
|
||||
'application/json',
|
||||
'application/geo+json',
|
||||
'image/png',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
]);
|
||||
const packsOf = (project) =>
|
||||
project.scenes.flatMap((scene) => scene.dataPacks || []);
|
||||
const checkAbort = (signal) => signal?.throwIfAborted();
|
||||
const digest = async (bytes) =>
|
||||
Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
|
||||
(n) => n.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
function encode(bytes) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < bytes.length; i += 32768)
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 32768)));
|
||||
return btoa(chunks.join(''));
|
||||
}
|
||||
function decode(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.length ||
|
||||
value.length > Math.ceil(PACK_LIMITS.bytes / 3) * 4 ||
|
||||
value.length % 4 !== 0 ||
|
||||
/[^A-Za-z0-9+/=]/.test(value) ||
|
||||
(value.includes('=') && !/^[A-Za-z0-9+/]+={1,2}$/.test(value))
|
||||
)
|
||||
fail('assets', 'invalid or oversized base64 asset');
|
||||
return Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
|
||||
}
|
||||
function checkBytes(bytes, total) {
|
||||
if (
|
||||
!(bytes instanceof Uint8Array) ||
|
||||
!bytes.length ||
|
||||
bytes.length > PACK_LIMITS.bytes ||
|
||||
total > PACK_LIMITS.totalBytes
|
||||
)
|
||||
fail('assets', 'asset byte limit exceeded');
|
||||
}
|
||||
function checkMime(mimeType) {
|
||||
if (!MIME.has(mimeType)) fail('assets', 'unsupported media type');
|
||||
}
|
||||
|
||||
/** Read inert scene JSON or a bounded asset bundle, verifying bytes before admission. */
|
||||
export async function parseSceneShare(text, { signal } = {}) {
|
||||
checkAbort(signal);
|
||||
if (
|
||||
typeof text !== 'string' ||
|
||||
text.length > SHARE_LIMITS.bytes ||
|
||||
new TextEncoder().encode(text).length > SHARE_LIMITS.bytes
|
||||
)
|
||||
fail('$', 'share exceeds 50 MiB');
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(text);
|
||||
} catch {
|
||||
fail('$', 'invalid JSON');
|
||||
}
|
||||
if (input?.format !== 'gev-scene-bundle')
|
||||
return { project: parseSceneDocument(text), assets: new Map() };
|
||||
fields(input, '$', ['format', 'version', 'project', 'assets']);
|
||||
if (input.version !== 1) fail('version', 'unsupported bundle version');
|
||||
const project = parseSceneDocument(JSON.stringify(input.project));
|
||||
array(input.assets, 'assets', SHARE_LIMITS.assets);
|
||||
const assets = new Map();
|
||||
let total = 0;
|
||||
for (const entry of input.assets) {
|
||||
checkAbort(signal);
|
||||
fields(entry, 'assets', ['path', 'mimeType', 'base64', 'sha256']);
|
||||
validateAssetPath(entry.path);
|
||||
checkMime(entry.mimeType);
|
||||
if (assets.has(entry.path)) fail('assets', 'duplicate asset path');
|
||||
const bytes = decode(entry.base64);
|
||||
total += bytes.length;
|
||||
checkBytes(bytes, total);
|
||||
const hash = await digest(bytes);
|
||||
checkAbort(signal);
|
||||
if (entry.sha256 !== hash) fail('assets', 'asset integrity mismatch');
|
||||
assets.set(entry.path, { bytes, mimeType: entry.mimeType, sha256: hash });
|
||||
}
|
||||
const used = new Set();
|
||||
for (const pack of packsOf(project)) {
|
||||
if (pack.source.adapter !== BUNDLE_SOURCE)
|
||||
fail('project', 'bundle must include every declared pack');
|
||||
const asset = assets.get(pack.source.path);
|
||||
used.add(pack.source.path);
|
||||
if (
|
||||
!asset ||
|
||||
pack.byteLength !== asset.bytes.length ||
|
||||
pack.sha256 !== asset.sha256
|
||||
)
|
||||
fail('project', 'missing or mismatched bundle asset');
|
||||
}
|
||||
if (used.size !== assets.size) fail('assets', 'unreferenced bundle asset');
|
||||
return { project, assets };
|
||||
}
|
||||
|
||||
/** File reads have a known budget before text decoding; large bundles use a distinct suffix. */
|
||||
export async function readSceneShare(file, options) {
|
||||
const limit = file.name?.endsWith('.gevbundle.json')
|
||||
? SHARE_LIMITS.bytes
|
||||
: 5 * 1024 * 1024;
|
||||
if (file.size > limit)
|
||||
fail(
|
||||
'$',
|
||||
limit === SHARE_LIMITS.bytes
|
||||
? 'share exceeds 50 MiB'
|
||||
: 'file exceeds 5 MiB',
|
||||
);
|
||||
checkAbort(options?.signal);
|
||||
const text = await withShareSignal(file.text(), options?.signal);
|
||||
checkAbort(options?.signal);
|
||||
return parseSceneShare(text, options);
|
||||
}
|
||||
|
||||
/** Export only explicitly supplied pack bytes; never fetch external assets or alter the source project. */
|
||||
export async function createSceneBundle(
|
||||
project,
|
||||
resolveAsset,
|
||||
{ signal } = {},
|
||||
) {
|
||||
const copy = parseSceneDocument(stringifySceneDocument(project));
|
||||
const assets = [],
|
||||
known = new Map();
|
||||
let total = 0;
|
||||
for (const pack of packsOf(copy)) {
|
||||
checkAbort(signal);
|
||||
const key = JSON.stringify([pack.source.adapter, pack.source.path]);
|
||||
let entry = known.get(key);
|
||||
if (!entry) {
|
||||
if (assets.length >= SHARE_LIMITS.assets)
|
||||
fail('assets', 'too many bundled assets');
|
||||
const asset = await withShareSignal(
|
||||
resolveAsset(pack, { signal }),
|
||||
signal,
|
||||
);
|
||||
checkAbort(signal);
|
||||
if (!asset) fail('assets', 'select a file for every declared data pack');
|
||||
const bytes = asset.bytes;
|
||||
total += bytes?.length || 0;
|
||||
checkBytes(bytes, total);
|
||||
checkMime(asset.mimeType);
|
||||
const sha256 = await digest(bytes);
|
||||
checkAbort(signal);
|
||||
if (
|
||||
(pack.byteLength && pack.byteLength !== bytes.length) ||
|
||||
(pack.sha256 && pack.sha256 !== sha256)
|
||||
)
|
||||
fail('assets', 'selected file does not match declared integrity');
|
||||
const path = `files/${assets.length}-${pack.source.path.split('/').at(-1).slice(0, 160)}`;
|
||||
entry = {
|
||||
path,
|
||||
mimeType: asset.mimeType,
|
||||
base64: encode(bytes),
|
||||
sha256,
|
||||
byteLength: bytes.length,
|
||||
};
|
||||
known.set(key, entry);
|
||||
assets.push(entry);
|
||||
} else if (
|
||||
(pack.byteLength && pack.byteLength !== entry.byteLength) ||
|
||||
(pack.sha256 && pack.sha256 !== entry.sha256)
|
||||
)
|
||||
fail('assets', 'conflicting shared asset integrity');
|
||||
pack.source = { adapter: BUNDLE_SOURCE, path: entry.path };
|
||||
pack.byteLength = entry.byteLength;
|
||||
pack.sha256 = entry.sha256;
|
||||
}
|
||||
const text = JSON.stringify({
|
||||
format: 'gev-scene-bundle',
|
||||
version: 1,
|
||||
project: copy,
|
||||
assets: assets.map(({ byteLength, ...entry }) => entry),
|
||||
});
|
||||
if (new TextEncoder().encode(text).length > SHARE_LIMITS.bytes)
|
||||
fail('$', 'share exceeds 50 MiB');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Keep bundled bytes in this project lifetime only; never fall back to a network source. */
|
||||
export function createBundleAssets() {
|
||||
let assets = new Map();
|
||||
return {
|
||||
replace(next = new Map()) {
|
||||
assets = new Map(next);
|
||||
},
|
||||
clear() {
|
||||
assets.clear();
|
||||
},
|
||||
snapshot: () => new Map(assets),
|
||||
getState: () => ({
|
||||
count: assets.size,
|
||||
bytes: [...assets.values()].reduce((n, a) => n + a.bytes.length, 0),
|
||||
}),
|
||||
source({ path, signal, maxBytes = PACK_LIMITS.bytes }) {
|
||||
checkAbort(signal);
|
||||
validateAssetPath(path);
|
||||
const asset = assets.get(path);
|
||||
if (!asset || asset.bytes.length > maxBytes)
|
||||
throw new Error('Bundle asset unavailable — reimport the bundle');
|
||||
return { bytes: asset.bytes.slice(), mimeType: asset.mimeType };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Settle cancelled file/adaptor work promptly; late results never regain ownership. */
|
||||
export function withShareSignal(work, signal) {
|
||||
if (!signal) return Promise.resolve(work);
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
reject(signal.reason);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
Promise.resolve(work).catch(() => {});
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
Promise.resolve(work).then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
signal.aborted ? reject(signal.reason) : resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BUNDLE_SOURCE } from './bundle.js';
|
||||
|
||||
/** Describe an import without fetching assets, applying state or exposing source configuration. */
|
||||
export function describeSceneShare(
|
||||
{ project, assets },
|
||||
{ sourceIds = [], layerIds = [] } = {},
|
||||
) {
|
||||
const sources = new Set(sourceIds),
|
||||
layers = new Set(layerIds);
|
||||
const packs = project.scenes.flatMap((scene) =>
|
||||
(scene.dataPacks || []).map((pack) => ({
|
||||
scene: scene.title || scene.id,
|
||||
id: pack.id,
|
||||
path: pack.source.path,
|
||||
attribution: pack.attribution,
|
||||
status:
|
||||
pack.source.adapter === BUNDLE_SOURCE
|
||||
? assets.has(pack.source.path)
|
||||
? 'Included in bundle'
|
||||
: 'Missing bundle file — reimport its bundle'
|
||||
: sources.has(pack.source.adapter)
|
||||
? 'Source configured; file checked when loaded'
|
||||
: 'Source unavailable',
|
||||
})),
|
||||
);
|
||||
return {
|
||||
scenes: project.scenes.length,
|
||||
shots: project.scenes.reduce((n, s) => n + s.shots.length, 0),
|
||||
packs,
|
||||
missingLayers: [
|
||||
...new Set(
|
||||
project.scenes.flatMap((s) =>
|
||||
s.shots.flatMap((shot) => Object.keys(shot.layers || {})),
|
||||
),
|
||||
),
|
||||
].filter((id) => !layers.has(id)),
|
||||
externalContent: project.scenes.some(
|
||||
(s) =>
|
||||
s.appliedShotPacks?.length || s.shots.some((shot) => shot.sourcePackId),
|
||||
),
|
||||
bundledBytes: [...assets.values()].reduce((n, a) => n + a.bytes.length, 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createSceneBundle,
|
||||
parseSceneShare,
|
||||
readSceneShare,
|
||||
createBundleAssets,
|
||||
BUNDLE_SOURCE,
|
||||
SHARE_LIMITS,
|
||||
} from './bundle.js';
|
||||
import { describeSceneShare } from './preview.js';
|
||||
import { editSceneDetails, selectSceneDocument } from '../authoring.js';
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||
);
|
||||
const fixture = () => ({
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'one',
|
||||
title: 'Example',
|
||||
dataPacks: [
|
||||
{
|
||||
id: 'data',
|
||||
version: 1,
|
||||
format: 'geojson',
|
||||
source: { adapter: 'assets', path: 'test/data.geojson' },
|
||||
attribution: {
|
||||
text: 'Example author',
|
||||
license: 'CC0-1.0',
|
||||
url: 'https://example.org/source',
|
||||
},
|
||||
placement: { altitudeReference: 'ellipsoid' },
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'shot',
|
||||
title: 'Original',
|
||||
durationSec: 3,
|
||||
holdSec: 2,
|
||||
camera: { lat: 1, lon: 2, alt: 300, pitch: -60 },
|
||||
layers: { traffic: false },
|
||||
dataPackIds: ['data'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'other', shots: [] },
|
||||
],
|
||||
});
|
||||
const asset = () => ({
|
||||
bytes: bytes.slice(),
|
||||
mimeType: 'application/geo+json',
|
||||
});
|
||||
|
||||
test('selected-scene bundles round trip bytes and attribution without mutating the project or fetching', async () => {
|
||||
const original = fixture(),
|
||||
before = JSON.stringify(original);
|
||||
let reads = 0;
|
||||
const text = await createSceneBundle(
|
||||
selectSceneDocument(original, 'one'),
|
||||
() => {
|
||||
reads++;
|
||||
return asset();
|
||||
},
|
||||
);
|
||||
const parsed = await parseSceneShare(text);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(JSON.stringify(original), before);
|
||||
assert.equal(parsed.project.scenes.length, 1);
|
||||
const pack = parsed.project.scenes[0].dataPacks[0];
|
||||
assert.equal(pack.source.adapter, BUNDLE_SOURCE);
|
||||
assert.equal(pack.byteLength, bytes.length);
|
||||
assert.deepEqual(
|
||||
pack.attribution,
|
||||
original.scenes[0].dataPacks[0].attribution,
|
||||
);
|
||||
assert.deepEqual(parsed.assets.get(pack.source.path).bytes, bytes);
|
||||
});
|
||||
|
||||
test('bundle rejects malformed bytes, unknown fields, traversal, duplicates, missing files and broken integrity', async () => {
|
||||
const valid = JSON.parse(await createSceneBundle(fixture(), () => asset()));
|
||||
const mutations = [
|
||||
(b) => (b.version = 2),
|
||||
(b) => (b.assets[0].path = '../secret'),
|
||||
(b) => (b.assets[0].base64 = 'A==='),
|
||||
(b) => (b.assets[0].base64 = '____'),
|
||||
(b) => (b.assets[0].base64 = btoa('different')),
|
||||
(b) => (b.assets[0].headers = {}),
|
||||
(b) => (b.assets[0].mimeType = 'text/html'),
|
||||
(b) => b.assets.push(b.assets[0]),
|
||||
(b) => (b.assets = []),
|
||||
(b) => (b.project.scenes[0].dataPacks[0].source.adapter = 'assets'),
|
||||
(b) => (b.project.scenes[0].dataPacks = []),
|
||||
(b) => (b.project.scenes[0].dataPacks[0].sha256 = '0'.repeat(64)),
|
||||
(b) => (b.assets = Array.from({ length: 65 }, () => b.assets[0])),
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const b = structuredClone(valid);
|
||||
mutate(b);
|
||||
await assert.rejects(parseSceneShare(JSON.stringify(b)));
|
||||
}
|
||||
});
|
||||
|
||||
test('asset caps and declared integrity are enforced before creating a downloadable bundle', async () => {
|
||||
await assert.rejects(
|
||||
createSceneBundle(fixture(), () => ({
|
||||
bytes: new Uint8Array(8 * 1024 * 1024 + 1),
|
||||
mimeType: 'image/png',
|
||||
})),
|
||||
/limit/,
|
||||
);
|
||||
const p = fixture();
|
||||
p.scenes[0].dataPacks[0].sha256 = '0'.repeat(64);
|
||||
await assert.rejects(
|
||||
createSceneBundle(p, () => asset()),
|
||||
/integrity/,
|
||||
);
|
||||
await assert.rejects(
|
||||
createSceneBundle(fixture(), () => null),
|
||||
/select a file/,
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate pack paths share one asset and reject conflicting integrity', async () => {
|
||||
const p = fixture();
|
||||
p.scenes[0].dataPacks.push({
|
||||
...structuredClone(p.scenes[0].dataPacks[0]),
|
||||
id: 'second',
|
||||
});
|
||||
let reads = 0;
|
||||
const text = await createSceneBundle(p, () => {
|
||||
reads++;
|
||||
return asset();
|
||||
});
|
||||
assert.equal(reads, 1);
|
||||
assert.equal((await parseSceneShare(text)).assets.size, 1);
|
||||
p.scenes[0].dataPacks[1].byteLength = 1;
|
||||
await assert.rejects(
|
||||
createSceneBundle(p, () => asset()),
|
||||
/integrity/,
|
||||
);
|
||||
});
|
||||
|
||||
test('preview reports unavailable sources/layers and missing bundle assets without applying anything', () => {
|
||||
const p = fixture();
|
||||
const report = describeSceneShare(
|
||||
{ project: p, assets: new Map() },
|
||||
{ sourceIds: [], layerIds: [] },
|
||||
);
|
||||
assert.equal(report.packs[0].status, 'Source unavailable');
|
||||
assert.deepEqual(report.missingLayers, ['traffic']);
|
||||
p.scenes[0].dataPacks[0].source.adapter = BUNDLE_SOURCE;
|
||||
assert.match(
|
||||
describeSceneShare({ project: p, assets: new Map() }).packs[0].status,
|
||||
/Missing bundle/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bundle byte owner releases replacement data and has no network fallback', async () => {
|
||||
const parsed = await parseSceneShare(
|
||||
await createSceneBundle(fixture(), () => asset()),
|
||||
);
|
||||
const store = createBundleAssets();
|
||||
store.replace(parsed.assets);
|
||||
const path = parsed.project.scenes[0].dataPacks[0].source.path;
|
||||
const first = store.source({ path });
|
||||
first.bytes[0] = 0;
|
||||
assert.deepEqual(store.source({ path }).bytes, bytes);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
assert.throws(() => store.source({ path, signal: controller.signal }));
|
||||
store.clear();
|
||||
assert.deepEqual(store.getState(), { count: 0, bytes: 0 });
|
||||
assert.throws(() => store.source({ path }), /unavailable/);
|
||||
});
|
||||
|
||||
test('oversized files fail before reading and cancellation settles a stalled file without a late result', async () => {
|
||||
let reads = 0;
|
||||
await assert.rejects(
|
||||
readSceneShare({
|
||||
name: 'large.json',
|
||||
size: 6 * 1024 * 1024,
|
||||
text: () => {
|
||||
reads++;
|
||||
},
|
||||
}),
|
||||
/5 MiB/,
|
||||
);
|
||||
await assert.rejects(
|
||||
readSceneShare({
|
||||
name: 'large.gevbundle.json',
|
||||
size: SHARE_LIMITS.bytes + 1,
|
||||
text: () => {
|
||||
reads++;
|
||||
},
|
||||
}),
|
||||
/50 MiB/,
|
||||
);
|
||||
assert.equal(reads, 0);
|
||||
let resolve;
|
||||
const owner = new AbortController();
|
||||
const work = readSceneShare(
|
||||
{ name: 'pending.json', text: () => new Promise((r) => (resolve = r)) },
|
||||
{ signal: owner.signal },
|
||||
);
|
||||
owner.abort();
|
||||
await assert.rejects(work, /abort/i);
|
||||
resolve(JSON.stringify(fixture()));
|
||||
});
|
||||
|
||||
test('cancelled bundle export never resolves another asset or produces partial output', async () => {
|
||||
let resolve;
|
||||
const owner = new AbortController();
|
||||
const work = createSceneBundle(
|
||||
fixture(),
|
||||
() => new Promise((r) => (resolve = r)),
|
||||
{ signal: owner.signal },
|
||||
);
|
||||
owner.abort();
|
||||
await assert.rejects(work, /abort/i);
|
||||
resolve(asset());
|
||||
});
|
||||
|
||||
test('details editing preserves content IDs, layers and provenance, rejects invalid drafts atomically', () => {
|
||||
const p = fixture(),
|
||||
before = JSON.stringify(p);
|
||||
p.scenes[0].shots[0].sourcePackId = 'existing';
|
||||
const scene = p.scenes[0],
|
||||
shot = scene.shots[0];
|
||||
const edited = editSceneDetails(
|
||||
p,
|
||||
'one',
|
||||
'shot',
|
||||
{
|
||||
anchors: [
|
||||
{ id: 'a', lat: 1, lon: 2, alt: 400, altitudeReference: 'ellipsoid' },
|
||||
],
|
||||
dataPacks: scene.dataPacks,
|
||||
},
|
||||
{
|
||||
camera: { anchorId: 'a' },
|
||||
durationSec: 4,
|
||||
holdSec: 0,
|
||||
dataPackIds: ['data'],
|
||||
},
|
||||
);
|
||||
assert.equal(edited.scenes[0].shots[0].sourcePackId, 'existing');
|
||||
assert.deepEqual(edited.scenes[0].shots[0].layers, shot.layers);
|
||||
assert.equal(edited.scenes[0].shots[0].id, 'shot');
|
||||
assert.throws(() =>
|
||||
editSceneDetails(
|
||||
p,
|
||||
'one',
|
||||
'shot',
|
||||
{ anchors: [] },
|
||||
{ camera: { anchorId: 'missing' } },
|
||||
),
|
||||
);
|
||||
assert.throws(() =>
|
||||
editSceneDetails(p, 'one', 'shot', { source: 'remote' }, {}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
editSceneDetails(p, 'one', 'shot', {}, { sourcePackId: 'replacement' }),
|
||||
);
|
||||
delete p.scenes[0].shots[0].sourcePackId;
|
||||
assert.equal(JSON.stringify(p), before);
|
||||
});
|
||||
|
||||
test('long valid source filenames still produce an importable bundle', async () => {
|
||||
const p = fixture();
|
||||
p.scenes[0].dataPacks[0].source.path = 'x'.repeat(1024);
|
||||
const parsed = await parseSceneShare(
|
||||
await createSceneBundle(p, () => asset()),
|
||||
);
|
||||
assert.equal(parsed.assets.size, 1);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export function createSceneDataPacks(viewer, { sources = {} } = {}) {
|
||||
});
|
||||
return {
|
||||
...session,
|
||||
sourceIds: () => Object.keys(sources),
|
||||
getTargets: () => new Map(targets),
|
||||
apply(scene, shot, token) {
|
||||
const wanted = new Set(shot.dataPackIds || []);
|
||||
|
||||
+73
-12
@@ -27,6 +27,12 @@ import {
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
import {
|
||||
createBundleAssets,
|
||||
BUNDLE_SOURCE,
|
||||
readSceneShare,
|
||||
} from '../director/sharing/bundle.js';
|
||||
import { createDefaultScenePacks } from './packs/defaults.js';
|
||||
import {
|
||||
layerStatesForShot,
|
||||
@@ -51,7 +57,6 @@ import {
|
||||
import {
|
||||
parseSceneDocument,
|
||||
stringifySceneDocument,
|
||||
SCENE_DOCUMENT_LIMITS,
|
||||
SceneDocumentError,
|
||||
} from '../director/document.js';
|
||||
|
||||
@@ -89,7 +94,15 @@ export class SceneDirector {
|
||||
this.dataManager = dataManager;
|
||||
this._isMapStackAvailable = isMapStackAvailable;
|
||||
this._scenePacks = scenePacks;
|
||||
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
|
||||
this._bundleAssets = createBundleAssets();
|
||||
this._dataPacks = createSceneDataPacks(viewer, {
|
||||
...dataPacks,
|
||||
sources: {
|
||||
...dataPacks.sources,
|
||||
[BUNDLE_SOURCE]: this._bundleAssets.source,
|
||||
},
|
||||
});
|
||||
this._sharing = createSceneSharing(this);
|
||||
this._interactionTransitions = 0;
|
||||
this._interactions = createSceneInteractions(viewer, {
|
||||
available: () =>
|
||||
@@ -217,6 +230,8 @@ export class SceneDirector {
|
||||
this._cameraHandoffUnsubscribe?.();
|
||||
this._removeCameraInput?.();
|
||||
this._cameraMotion?.destroy();
|
||||
this._sharing?.destroy();
|
||||
this._bundleAssets?.clear();
|
||||
this._interactions?.destroy();
|
||||
this._dataPacks?.destroy();
|
||||
this._controls?.destroy();
|
||||
@@ -369,6 +384,7 @@ export class SceneDirector {
|
||||
* Exits silently if the scene-select element is missing (headless/test mode).
|
||||
*/
|
||||
_initUI() {
|
||||
this._sharing?.mount();
|
||||
this._controls = new SceneControls({
|
||||
subscribe: (listener) => this.subscribe(listener),
|
||||
read: () => ({
|
||||
@@ -404,6 +420,7 @@ export class SceneDirector {
|
||||
next: () => this.runNextScene(),
|
||||
export: () => this.exportProject(),
|
||||
import: (file) => this.importProjectFile(file),
|
||||
reviewImport: (file) => this._sharing.preview(file),
|
||||
download: () => this.downloadLastRunMetadata(),
|
||||
load: (sceneId, shotId) => this.loadShot(sceneId, shotId),
|
||||
deleteShot: (sceneId, shotId) => this.deleteShot(sceneId, shotId),
|
||||
@@ -1851,6 +1868,14 @@ export class SceneDirector {
|
||||
);
|
||||
}
|
||||
|
||||
/** Copied authoring and bundled-byte diagnostics for lifecycle checks. */
|
||||
getSharingState() {
|
||||
return {
|
||||
...this._sharing?.getState(),
|
||||
assets: this._bundleAssets?.getState() || { count: 0, bytes: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Export the entire project as a timestamped JSON file download. */
|
||||
exportProject() {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
@@ -1881,30 +1906,66 @@ export class SceneDirector {
|
||||
* The file is normalized/migrated on load; invalid JSON shows an error status.
|
||||
* @param {File} file - Browser File object from an <input type="file">
|
||||
*/
|
||||
async importProjectFile(file) {
|
||||
async importProjectFile(
|
||||
file,
|
||||
{ prepared, expectedProject, selection, signal } = {},
|
||||
) {
|
||||
const generation = (this._importGeneration =
|
||||
(this._importGeneration || 0) + 1);
|
||||
try {
|
||||
if (file.size > SCENE_DOCUMENT_LIMITS.bytes) {
|
||||
throw new SceneDocumentError('$', 'file exceeds 5 MiB');
|
||||
}
|
||||
const text = await file.text();
|
||||
if (this._destroyed || generation !== this._importGeneration) return;
|
||||
const project = normalizeProject(parseSceneDocument(text));
|
||||
if (!prepared) this._sharing?.close();
|
||||
const input = prepared || (await readSceneShare(file, { signal }));
|
||||
if (signal?.aborted) return false;
|
||||
if (this._destroyed || generation !== this._importGeneration)
|
||||
return false;
|
||||
const project = normalizeProject(
|
||||
parseSceneDocument(stringifySceneDocument(input.project)),
|
||||
);
|
||||
if (
|
||||
expectedProject &&
|
||||
expectedProject !== JSON.stringify(this._project, null, 2)
|
||||
)
|
||||
return false;
|
||||
// Validate before touching playback, selection or the saved project.
|
||||
this.stopScene('Importing project');
|
||||
await Promise.allSettled([...(this._pendingWork || [])]);
|
||||
if (this._destroyed || generation !== this._importGeneration) return;
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(expectedProject &&
|
||||
expectedProject !== JSON.stringify(this._project, null, 2))
|
||||
)
|
||||
return false;
|
||||
this._project = project;
|
||||
const retainedPaths = new Set(
|
||||
project.scenes.flatMap((scene) =>
|
||||
(scene.dataPacks || [])
|
||||
.filter((pack) => pack.source.adapter === BUNDLE_SOURCE)
|
||||
.map((pack) => pack.source.path),
|
||||
),
|
||||
);
|
||||
this._bundleAssets?.replace(
|
||||
new Map(
|
||||
[...(input.assets || [])].filter(([path]) => retainedPaths.has(path)),
|
||||
),
|
||||
);
|
||||
this._storageReadError = null;
|
||||
this._selectedSceneId = project.scenes[0]?.id || null;
|
||||
this._selectedShotId = project.scenes[0]?.shots[0]?.id || null;
|
||||
this._selectedSceneId =
|
||||
selection?.sceneId || project.scenes[0]?.id || null;
|
||||
this._selectedShotId =
|
||||
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
|
||||
this._loadedSceneId = null;
|
||||
this._saveProject();
|
||||
this._publish({ type: 'project-imported', project });
|
||||
this._updateStatus(`Imported ${file.name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this._destroyed || generation !== this._importGeneration) return;
|
||||
if (
|
||||
signal?.aborted ||
|
||||
this._destroyed ||
|
||||
generation !== this._importGeneration
|
||||
)
|
||||
return false;
|
||||
this._updateStatus(
|
||||
error instanceof SceneDocumentError
|
||||
? `Import failed: ${error.message}`
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { withShareSignal } from '../director/sharing/lifetime.js';
|
||||
import {
|
||||
readSceneShare,
|
||||
createSceneBundle,
|
||||
BUNDLE_SOURCE,
|
||||
} from '../director/sharing/bundle.js';
|
||||
import { describeSceneShare } from '../director/sharing/preview.js';
|
||||
import { stringifySceneDocument } from '../director/document.js';
|
||||
import {
|
||||
editSceneDetails,
|
||||
selectSceneDocument,
|
||||
} from '../director/authoring.js';
|
||||
import { createSceneDialog, mountSceneSharing } from '../ui/sceneSharing.js';
|
||||
import { PACK_LIMITS } from '../director/packs/manifest.js';
|
||||
|
||||
function download(text, name) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([text], { type: 'application/json' }),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
document.body.append(link);
|
||||
try {
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
const subset = (value, keys) =>
|
||||
Object.fromEntries(
|
||||
keys
|
||||
.filter((key) => Object.hasOwn(value, key))
|
||||
.map((key) => [key, value[key]]),
|
||||
);
|
||||
const sceneKeys = ['anchors', 'dataPacks'];
|
||||
const shotKeys = [
|
||||
'camera',
|
||||
'move',
|
||||
'durationSec',
|
||||
'holdSec',
|
||||
'dataPackIds',
|
||||
'interactions',
|
||||
];
|
||||
const json = (value) => JSON.stringify(value, null, 2);
|
||||
const mimeFor = (file) =>
|
||||
file.type ||
|
||||
{
|
||||
json: 'application/json',
|
||||
geojson: 'application/geo+json',
|
||||
png: 'image/png',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
}[file.name.split('.').at(-1)];
|
||||
|
||||
/** Own staged imports, author drafts and share bytes separately from playback and project state. */
|
||||
export function createSceneSharing(director) {
|
||||
let dialog,
|
||||
controller,
|
||||
disposed = false,
|
||||
staged = null,
|
||||
busy = false;
|
||||
const alive = (owner) =>
|
||||
!disposed && controller === owner && !owner.signal.aborted;
|
||||
function close() {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
dialog?.dispose();
|
||||
dialog = null;
|
||||
staged = null;
|
||||
busy = false;
|
||||
}
|
||||
function open(title) {
|
||||
close();
|
||||
if (disposed) return null;
|
||||
controller = new AbortController();
|
||||
dialog = createSceneDialog(title, close);
|
||||
return controller;
|
||||
}
|
||||
async function run(owner, job) {
|
||||
if (!alive(owner) || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await job();
|
||||
} catch (error) {
|
||||
if (alive(owner))
|
||||
dialog.status.textContent =
|
||||
error?.name === 'SceneDocumentError'
|
||||
? error.message
|
||||
: 'Could not complete this action. Check the file, references and selected assets.';
|
||||
} finally {
|
||||
if (alive(owner)) busy = false;
|
||||
}
|
||||
}
|
||||
const currentProject = () => json(director._project);
|
||||
function inventory(input) {
|
||||
const report = describeSceneShare(input, {
|
||||
sourceIds: director._dataPacks.sourceIds(),
|
||||
layerIds: director.dataManager.getAll().map((l) => l.id),
|
||||
});
|
||||
dialog.text(
|
||||
`${report.scenes} scenes · ${report.shots} shots · ${report.packs.length} data packs`,
|
||||
);
|
||||
for (const pack of report.packs)
|
||||
dialog.text(
|
||||
`${pack.scene} / ${pack.id}: ${pack.status}. File: ${pack.path}. ${pack.attribution.text} · ${pack.attribution.license}`,
|
||||
);
|
||||
if (report.missingLayers.length)
|
||||
dialog.text(`Unavailable layers: ${report.missingLayers.join(', ')}`);
|
||||
if (report.externalContent)
|
||||
dialog.text(
|
||||
'This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.',
|
||||
);
|
||||
if (report.bundledBytes)
|
||||
dialog.text(
|
||||
`${report.bundledBytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.`,
|
||||
);
|
||||
return report;
|
||||
}
|
||||
async function preview(file) {
|
||||
const owner = open('Review scene import');
|
||||
if (!owner) return;
|
||||
const expected = currentProject();
|
||||
dialog.text('Nothing is loaded or changed until you apply this file.');
|
||||
await run(owner, async () => {
|
||||
const input = await readSceneShare(file, { signal: owner.signal });
|
||||
if (!alive(owner)) return;
|
||||
staged = input;
|
||||
inventory(input);
|
||||
dialog.status.textContent = 'Ready to import';
|
||||
dialog.text(
|
||||
'Apply replaces the current project. Export your current project first if you want to keep both.',
|
||||
);
|
||||
const apply = dialog.button('Apply import', () =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
apply.disabled = true;
|
||||
const ok = await director.importProjectFile(file, {
|
||||
prepared: input,
|
||||
expectedProject: expected,
|
||||
signal: owner.signal,
|
||||
});
|
||||
if (alive(owner)) {
|
||||
if (ok) close();
|
||||
else {
|
||||
apply.disabled = false;
|
||||
dialog.status.textContent =
|
||||
'Import was not applied. The current project may have changed.';
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
apply.dataset.directorApplyImport = '';
|
||||
});
|
||||
}
|
||||
function edit() {
|
||||
const scene = director._getSelectedScene(),
|
||||
shot = scene?.shots.find((s) => s.id === director._selectedShotId);
|
||||
if (!scene || !shot) {
|
||||
director._updateStatus('Select a scene and shot first');
|
||||
return;
|
||||
}
|
||||
const owner = open('Edit scene details');
|
||||
if (!owner) return;
|
||||
const original = structuredClone(director._project),
|
||||
expected = currentProject();
|
||||
dialog.text(
|
||||
'Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.',
|
||||
);
|
||||
const sceneText = dialog.input(
|
||||
'Anchors and data packs',
|
||||
json(subset(scene, sceneKeys)),
|
||||
{ multiline: true },
|
||||
);
|
||||
const shotText = dialog.input(
|
||||
'Shot camera, timing, packs and actions',
|
||||
json(subset(shot, shotKeys)),
|
||||
{ multiline: true },
|
||||
);
|
||||
const anchorName = dialog.input('New anchor ID', 'anchor-1');
|
||||
dialog.button(
|
||||
'Capture camera as anchor',
|
||||
() =>
|
||||
run(owner, async () => {
|
||||
const details = JSON.parse(sceneText.value),
|
||||
camera = director.styleManager.getCameraState();
|
||||
if (!camera) throw new Error('Camera unavailable');
|
||||
const anchors = details.anchors || [];
|
||||
if (anchors.some((a) => a.id === anchorName.value))
|
||||
throw new Error('Duplicate anchor');
|
||||
details.anchors = [
|
||||
...anchors,
|
||||
{
|
||||
id: anchorName.value,
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
alt: camera.alt,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
];
|
||||
editSceneDetails(
|
||||
original,
|
||||
scene.id,
|
||||
shot.id,
|
||||
details,
|
||||
JSON.parse(shotText.value),
|
||||
);
|
||||
sceneText.value = json(details);
|
||||
dialog.status.textContent = 'Anchor added to draft';
|
||||
}),
|
||||
dialog.body,
|
||||
);
|
||||
dialog.button(
|
||||
'Set move start to current camera',
|
||||
() =>
|
||||
run(owner, async () => {
|
||||
const details = JSON.parse(shotText.value),
|
||||
camera = director.styleManager.getCameraState();
|
||||
if (!camera) throw new Error('Camera unavailable');
|
||||
details.move = {
|
||||
from: {
|
||||
...subset(camera, [
|
||||
'lat',
|
||||
'lon',
|
||||
'alt',
|
||||
'heading',
|
||||
'pitch',
|
||||
'roll',
|
||||
]),
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
easing: 'cubic-in-out',
|
||||
};
|
||||
if (!details.camera.anchorId)
|
||||
details.camera.altitudeReference = 'ellipsoid';
|
||||
details.durationSec = Math.max(0.2, details.durationSec || 3);
|
||||
editSceneDetails(
|
||||
original,
|
||||
scene.id,
|
||||
shot.id,
|
||||
JSON.parse(sceneText.value),
|
||||
details,
|
||||
);
|
||||
shotText.value = json(details);
|
||||
dialog.status.textContent =
|
||||
'Move added to draft; destination remains the shot camera';
|
||||
}),
|
||||
dialog.body,
|
||||
);
|
||||
dialog.button(
|
||||
'Use ordinary flight',
|
||||
() => {
|
||||
if (!alive(owner)) return;
|
||||
try {
|
||||
const details = JSON.parse(shotText.value);
|
||||
delete details.move;
|
||||
shotText.value = json(details);
|
||||
} catch {
|
||||
dialog.status.textContent = 'Invalid shot JSON';
|
||||
}
|
||||
},
|
||||
dialog.body,
|
||||
);
|
||||
const apply = dialog.button('Apply details', () =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
const project = editSceneDetails(
|
||||
original,
|
||||
scene.id,
|
||||
shot.id,
|
||||
JSON.parse(sceneText.value),
|
||||
JSON.parse(shotText.value),
|
||||
);
|
||||
const ok = await director.importProjectFile(
|
||||
{
|
||||
name: 'scene details',
|
||||
text: async () => stringifySceneDocument(project),
|
||||
},
|
||||
{
|
||||
prepared: { project, assets: director._bundleAssets.snapshot() },
|
||||
expectedProject: expected,
|
||||
signal: owner.signal,
|
||||
selection: { sceneId: scene.id, shotId: shot.id },
|
||||
},
|
||||
);
|
||||
if (alive(owner) && ok) close();
|
||||
}),
|
||||
);
|
||||
apply.dataset.directorApplyDetails = '';
|
||||
}
|
||||
function share() {
|
||||
let project;
|
||||
try {
|
||||
project = selectSceneDocument(
|
||||
director._project,
|
||||
director._selectedSceneId,
|
||||
);
|
||||
} catch {
|
||||
director._updateStatus('Select a scene first');
|
||||
return;
|
||||
}
|
||||
const owner = open('Share selected scene');
|
||||
if (!owner) return;
|
||||
const paths = new Set(
|
||||
project.scenes.flatMap((s) =>
|
||||
(s.dataPacks || [])
|
||||
.filter((p) => p.source.adapter === BUNDLE_SOURCE)
|
||||
.map((p) => p.source.path),
|
||||
),
|
||||
);
|
||||
const existing = new Map(
|
||||
[...director._bundleAssets.snapshot()].filter(([path]) =>
|
||||
paths.has(path),
|
||||
),
|
||||
);
|
||||
staged = { project, assets: existing };
|
||||
inventory(staged);
|
||||
dialog.text(
|
||||
'Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.',
|
||||
);
|
||||
dialog.button('Download scene JSON', () => {
|
||||
if (alive(owner)) download(stringifySceneDocument(project), 'scene.json');
|
||||
});
|
||||
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
|
||||
files.multiple = true;
|
||||
const folder = dialog.input('Or choose a data-pack folder', '', {
|
||||
type: 'file',
|
||||
});
|
||||
folder.multiple = true;
|
||||
folder.setAttribute('webkitdirectory', '');
|
||||
dialog.button('Download asset bundle', () =>
|
||||
run(owner, async () => {
|
||||
const selected = [...files.files, ...folder.files];
|
||||
const packs = project.scenes.flatMap((s) => s.dataPacks || []);
|
||||
const text = await createSceneBundle(
|
||||
project,
|
||||
async (pack, { signal }) => {
|
||||
if (
|
||||
pack.source.adapter === BUNDLE_SOURCE &&
|
||||
existing.has(pack.source.path)
|
||||
)
|
||||
return existing.get(pack.source.path);
|
||||
const exact = selected.filter(
|
||||
(f) =>
|
||||
f.webkitRelativePath === pack.source.path ||
|
||||
f.webkitRelativePath?.split('/').slice(1).join('/') ===
|
||||
pack.source.path,
|
||||
);
|
||||
const name = pack.source.path.split('/').at(-1),
|
||||
matches = exact.length
|
||||
? exact
|
||||
: selected.filter((f) => f.name === name);
|
||||
const keys = new Set(
|
||||
packs
|
||||
.filter((p) => p.source.path.split('/').at(-1) === name)
|
||||
.map((p) => JSON.stringify(p.source)),
|
||||
);
|
||||
if (matches.length !== 1 || (!exact.length && keys.size > 1))
|
||||
throw new Error('Missing or ambiguous file');
|
||||
const file = matches[0];
|
||||
if (!file.size || file.size > PACK_LIMITS.bytes)
|
||||
throw new Error('Asset size limit');
|
||||
const bytes = new Uint8Array(
|
||||
await withShareSignal(file.arrayBuffer(), signal),
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
return { bytes, mimeType: mimeFor(file) };
|
||||
},
|
||||
{ signal: owner.signal },
|
||||
);
|
||||
if (alive(owner)) {
|
||||
download(text, 'scene.gevbundle.json');
|
||||
dialog.status.textContent = 'Asset bundle downloaded';
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
const unmount = () => {};
|
||||
let removeToolbar = unmount;
|
||||
return {
|
||||
preview,
|
||||
edit,
|
||||
share,
|
||||
close,
|
||||
mount() {
|
||||
removeToolbar();
|
||||
removeToolbar = mountSceneSharing({ edit, share });
|
||||
},
|
||||
getState: () => ({
|
||||
open: !!dialog,
|
||||
busy,
|
||||
stagedAssets: staged?.assets.size || 0,
|
||||
}),
|
||||
destroy() {
|
||||
disposed = true;
|
||||
close();
|
||||
removeToolbar();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,10 @@ export class SceneControls {
|
||||
this.listen(elements.file, 'change', async () => {
|
||||
const file = elements.file?.files?.[0];
|
||||
if (!file) return;
|
||||
await this.run('import', file);
|
||||
await this.run(
|
||||
this.actions.reviewImport ? 'reviewImport' : 'import',
|
||||
file,
|
||||
);
|
||||
if (!this.destroyed) elements.file.value = '';
|
||||
});
|
||||
if (!subscribe) {
|
||||
|
||||
@@ -24,6 +24,16 @@ function fixture() {
|
||||
}
|
||||
appendChild(element) {
|
||||
this.children.push(element);
|
||||
element.parentNode = this;
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.appendChild(node);
|
||||
}
|
||||
remove() {
|
||||
if (this.parentNode)
|
||||
this.parentNode.children = this.parentNode.children.filter(
|
||||
(node) => node !== this,
|
||||
);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.dataset.directorDialog = '';
|
||||
dialog.className = 'director-sharing-dialog';
|
||||
dialog.setAttribute('aria-label', title);
|
||||
Object.assign(dialog.style, {
|
||||
background: '#08141e',
|
||||
color: '#e3faff',
|
||||
border: '1px solid #397080',
|
||||
borderRadius: '6px',
|
||||
padding: '20px',
|
||||
width: 'min(720px,calc(100vw - 32px))',
|
||||
boxSizing: 'border-box',
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
margin: 'auto',
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
overflow: 'auto',
|
||||
font: '14px/1.5 sans-serif',
|
||||
});
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = title;
|
||||
heading.style.marginBottom = '12px';
|
||||
dialog.append(heading);
|
||||
const body = document.createElement('div'),
|
||||
footer = document.createElement('div'),
|
||||
status = document.createElement('p');
|
||||
status.setAttribute('role', 'status');
|
||||
dialog.append(body, status, footer);
|
||||
const listeners = [];
|
||||
function listen(node, type, fn) {
|
||||
node.addEventListener(type, fn);
|
||||
listeners.push(() => node.removeEventListener(type, fn));
|
||||
}
|
||||
function text(value, parent = body) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = value;
|
||||
p.style.margin = '8px 0';
|
||||
parent.append(p);
|
||||
return p;
|
||||
}
|
||||
function button(label, fn, parent = footer) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
Object.assign(b.style, {
|
||||
background: '#10242e',
|
||||
color: '#e3faff',
|
||||
border: '1px solid #397080',
|
||||
borderRadius: '3px',
|
||||
padding: '7px 11px',
|
||||
margin: '6px 6px 6px 0',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
});
|
||||
listen(b, 'click', fn);
|
||||
parent.append(b);
|
||||
return b;
|
||||
}
|
||||
function input(label, value, { type = 'text', multiline = false } = {}) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.style.display = 'block';
|
||||
wrapper.textContent = label;
|
||||
const node = document.createElement(multiline ? 'textarea' : 'input');
|
||||
if (!multiline) node.type = type;
|
||||
node.value = value;
|
||||
node.setAttribute('aria-label', label);
|
||||
Object.assign(node.style, {
|
||||
display: 'block',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
padding: '7px',
|
||||
margin: '6px 0 14px',
|
||||
color: '#e3faff',
|
||||
background: '#10242e',
|
||||
border: '1px solid #397080',
|
||||
font: multiline ? '12px monospace' : 'inherit',
|
||||
});
|
||||
if (multiline) node.rows = 9;
|
||||
wrapper.append(node);
|
||||
body.append(wrapper);
|
||||
return node;
|
||||
}
|
||||
button('Cancel', onClose);
|
||||
listen(dialog, 'cancel', (event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
return {
|
||||
body,
|
||||
footer,
|
||||
status,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
listen,
|
||||
element: dialog,
|
||||
dispose() {
|
||||
for (const remove of listeners.splice(0)) remove();
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Add controls only when their owner is present; dispose removes every listener and element. */
|
||||
export function mountSceneSharing(
|
||||
{ edit, share },
|
||||
panel = document.getElementById('scene-panel'),
|
||||
) {
|
||||
if (!panel) return () => {};
|
||||
const bar = document.createElement('div');
|
||||
bar.dataset.directorAuthoring = '';
|
||||
bar.className = 'scene-controls';
|
||||
const entries = [
|
||||
['EDIT DETAILS', edit],
|
||||
['SHARE SCENE', share],
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, fn] of entries) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = text;
|
||||
b.className = 'scene-btn';
|
||||
b.addEventListener('click', fn);
|
||||
removers.push(() => b.removeEventListener('click', fn));
|
||||
bar.append(b);
|
||||
}
|
||||
(panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
|
||||
return () => {
|
||||
for (const remove of removers) remove();
|
||||
bar.remove();
|
||||
};
|
||||
}
|
||||
@@ -450,3 +450,7 @@
|
||||
#param-slider-panel.collapsed .param-panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.director-sharing-dialog::backdrop {
|
||||
background: rgba(0, 8, 14, 0.65);
|
||||
}
|
||||
Reference in new issue
Block a user