Add Director authoring, import previews and scene bundles

This commit is contained in:
Sameh Khamis committed 2026-09-15 20:15:32 -07:00
1 parent 62f66c4b86
commit 078fc290ec
25 files changed
+1930 -48

No files matched your search

+1
View File
@@ -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
View File
@@ -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}`
+404
View File
@@ -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();
},
};
}