mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
405 lines
13 KiB
JavaScript
405 lines
13 KiB
JavaScript
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();
|
|
},
|
|
};
|
|
}
|