mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Add Director import previews, validated scene/shot detail drafts and selected-scene
|
||||||
|
JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes
|
||||||
|
before admission and release staged work on cancellation or teardown.
|
||||||
|
|
||||||
- Director scene documents now support bounded data-pack manifests, per-shot
|
- Director scene documents now support bounded data-pack manifests, per-shot
|
||||||
selection and registered GeoJSON/PNG/media loaders with explicit placement,
|
selection and registered GeoJSON/PNG/media loaders with explicit placement,
|
||||||
visible attribution and cancellation/disposal on Stop or replacement.
|
visible attribution and cancellation/disposal on Stop or replacement.
|
||||||
|
|||||||
@@ -237,3 +237,8 @@ FIRMS refactoring can be checked without a configured server key using
|
|||||||
fixture mode exercises populated display, aggregation, cards, keyless and stale
|
fixture mode exercises populated display, aggregation, cards, keyless and stale
|
||||||
responses, and selection/camera handoff. It does not establish live-source
|
responses, and selection/camera handoff. It does not establish live-source
|
||||||
acceptance; omit `--fixtures` with a configured FIRMS key for that check.
|
acceptance; omit `--fixtures` with a configured FIRMS key for that check.
|
||||||
|
|
||||||
|
Director authoring and sharing acceptance: `node scripts/qa-director-sharing.mjs`
|
||||||
|
checks installed import previews, draft validation, file-bundle round trips,
|
||||||
|
cancellation, resource ownership and narrow-screen controls. Run alongside the
|
||||||
|
scene-controls, camera, pack, interaction and timing harnesses.
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Director imports now open a non-mutating preview before Apply. EDIT DETAILS
|
||||||
|
authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports
|
||||||
|
a selected scene or a bounded bundle of explicitly chosen pack files. Bundled
|
||||||
|
bytes remain in memory until replacement/teardown and require reimport after a
|
||||||
|
page reload. Stop releases rendering while retaining replay bytes. Cancellation
|
||||||
|
and stale drafts cannot replace newer project state. See [authoring and sharing](DIRECTOR-SHARING.md).
|
||||||
|
|
||||||
Director version-6 documents add bounded, scene-local feature actions. Settled
|
Director version-6 documents add bounded, scene-local feature actions. Settled
|
||||||
LOAD/seek exposes keyboard-accessible text/source cards, anchor focus, explicit
|
LOAD/seek exposes keyboard-accessible text/source cards, anchor focus, explicit
|
||||||
shot transitions and admitted layer state changes. Pointer claims take priority;
|
shot transitions and admitted layer state changes. Pointer claims take priority;
|
||||||
@@ -48,9 +55,9 @@ holds; stale tick callbacks cannot publish into a replacement. Registered pack
|
|||||||
rules own presentation overrides and Nepal map fallback without changing saved
|
rules own presentation overrides and Nepal map fallback without changing saved
|
||||||
shots or content. Scene-document validation and legacy migration now have separate
|
shots or content. Scene-document validation and legacy migration now have separate
|
||||||
owners. Invalid imports preserve the current project; unreadable saved projects
|
owners. Invalid imports preserve the current project; unreadable saved projects
|
||||||
are protected from fallback writes. Version 5 is the export format, with
|
are protected from fallback writes. Version 6 is the export format, with
|
||||||
zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md);
|
zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md);
|
||||||
interactions remain planned in [Director](DIRECTOR.md).
|
see [Director](DIRECTOR.md) for camera, pack, action and sharing support.
|
||||||
|
|
||||||
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged.
|
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Version 4 scene documents can name geographic anchors and describe an explicit
|
Version 4 scene documents can name geographic anchors and describe an explicit
|
||||||
camera move followed by a hold. Existing camera-pose shots still use the ordinary
|
camera move followed by a hold. Existing camera-pose shots still use the ordinary
|
||||||
camera flight. Import, playback, Load, Replay and seek accept the new directions;
|
camera flight. Import, playback, Load, Replay and seek accept the new directions;
|
||||||
a visual anchor/path editor is a later authoring step.
|
[EDIT DETAILS](DIRECTOR-SHARING.md) captures anchors and move starts; a graphical
|
||||||
|
path editor remains a later extension.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ is a lowercase hexadecimal SHA-256 digest checked before rendering. There are at
|
|||||||
most 8 packs per scene, 8 MiB per asset, and 32 MiB of selected assets per shot.
|
most 8 packs per scene, 8 MiB per asset, and 32 MiB of selected assets per shot.
|
||||||
GeoJSON additionally allows at most 2,000 features and 50,000 positions.
|
GeoJSON additionally allows at most 2,000 features and 50,000 positions.
|
||||||
Transforms, terrain-relative placement, terrain collision avoidance, arbitrary
|
Transforms, terrain-relative placement, terrain collision avoidance, arbitrary
|
||||||
GeoJSON styles, archives and local filesystem bundles are not implemented.
|
GeoJSON styles and archive extraction are not implemented. Explicit local files
|
||||||
|
can be shared through [JSON asset bundles](DIRECTOR-SHARING.md).
|
||||||
|
|
||||||
## Loading and resource ownership
|
## Loading and resource ownership
|
||||||
|
|
||||||
@@ -110,5 +111,5 @@ status and resource counts for lifecycle diagnostics.
|
|||||||
|
|
||||||
Run `node scripts/qa-director-packs.mjs` against a credentialed local server to
|
Run `node scripts/qa-director-packs.mjs` against a credentialed local server to
|
||||||
exercise the real import/load/seek/Stop path with self-authored synthetic assets.
|
exercise the real import/load/seek/Stop path with self-authored synthetic assets.
|
||||||
This stage provides the manifest/loading boundary; interactive actions and
|
See [actions](DIRECTOR-INTERACTIONS.md) and [authoring/sharing](DIRECTOR-SHARING.md)
|
||||||
visual authoring follow separately.
|
for the controls built on this manifest/loading boundary.
|
||||||
@@ -62,6 +62,5 @@ interaction owner supplies accessible DOM and picking; pack renderers own the
|
|||||||
feature identities and release them with their geometry. Layer/source admission
|
feature identities and release them with their geometry. Layer/source admission
|
||||||
and camera arbitration remain with their existing owners.
|
and camera arbitration remain with their existing owners.
|
||||||
|
|
||||||
Visual authoring, import previews and share bundles remain separate follow-up
|
Author actions in scene JSON or the validated EDIT DETAILS draft.
|
||||||
work. For now, author actions in scene JSON and use the existing import/export
|
[Import preview and file sharing](DIRECTOR-SHARING.md) preserve those declarations. Existing content, assets, notices and contributor credit are preserved.
|
||||||
controls. Existing content, assets, notices and contributor credit are preserved.
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Director authoring and file sharing
|
||||||
|
|
||||||
|
Open Scenes and select a scene and shot. Existing capture, update, playback and
|
||||||
|
whole-project export controls remain available. Authored scene content, source
|
||||||
|
links, assets and contributor credit are preserved.
|
||||||
|
|
||||||
|
## Review imports before applying
|
||||||
|
|
||||||
|
IMPORT accepts scene JSON (including older supported versions) or a
|
||||||
|
`.gevbundle.json` asset bundle. A preview lists scenes, shots, data-pack attribution,
|
||||||
|
configured or unavailable sources, missing layer IDs and bundled bytes. Preview
|
||||||
|
makes no asset requests and does not change the current project. Configured
|
||||||
|
sources do not guarantee that a file exists: ordinary assets are checked on LOAD.
|
||||||
|
Bundle preview verifies the manifest and byte integrity; format decoding and
|
||||||
|
geometry limits are still enforced when rendering.
|
||||||
|
|
||||||
|
Apply replaces the current project after validation and playback cleanup. Export
|
||||||
|
your current project first if you want to keep both. Cancel or Escape discards
|
||||||
|
the staged import. A changed project invalidates an open draft rather than being
|
||||||
|
overwritten. Invalid files leave the existing project and browser save intact.
|
||||||
|
The programmatic `importProjectFile(file)` API still applies trusted callers'
|
||||||
|
imports directly; the installed file chooser supplies the review step.
|
||||||
|
|
||||||
|
## Edit scene details
|
||||||
|
|
||||||
|
EDIT DETAILS provides validated JSON fields for:
|
||||||
|
|
||||||
|
- Scene anchors and data-pack manifests.
|
||||||
|
- The selected shot's camera, optional move, duration, hold, pack IDs and actions.
|
||||||
|
|
||||||
|
Capture camera as anchor adds a named geographic position to the draft. Set move
|
||||||
|
start to current camera creates a pose-to-pose move ending at the shot's existing
|
||||||
|
camera; Use ordinary flight removes that explicit move. Heights are meters above
|
||||||
|
the WGS84 ellipsoid. Apply details validates the complete project, preserves IDs,
|
||||||
|
layer declarations and content notices, and keeps the selected shot. Cancel
|
||||||
|
leaves the project unchanged. See [camera directions](DIRECTOR-CAMERA.md),
|
||||||
|
[data packs](DIRECTOR-DATA-PACKS.md) and [actions](DIRECTOR-INTERACTIONS.md) for
|
||||||
|
field definitions. This is a basic draft editor, not a node graph or path editor.
|
||||||
|
|
||||||
|
## Share a selected scene
|
||||||
|
|
||||||
|
SHARE SCENE exports only the selected scene. Download scene JSON preserves its
|
||||||
|
camera, timing, layers, anchors, manifests, interactions and attribution, but
|
||||||
|
contains no asset bytes. The recipient needs the referenced source registrations
|
||||||
|
and files. Existing EXPORT PRESETS continues to export the entire project.
|
||||||
|
|
||||||
|
For a bundle, select the declared pack files or a folder, then Download asset
|
||||||
|
bundle. Folder paths match a manifest path either directly or after removing the
|
||||||
|
chosen root directory. Individual filenames must be unambiguous across all pack
|
||||||
|
sources; ambiguous or missing matches fail without downloading a partial bundle.
|
||||||
|
Previously imported bundle files can be reused. Export never automatically fetches
|
||||||
|
remote files or copies registered recipe media. Existing integrity declarations
|
||||||
|
must match the files you supply.
|
||||||
|
|
||||||
|
Bundles include only declared data-pack files. Registered scene content, linked
|
||||||
|
media, map tiles and live data are not included. Source notices and licenses remain
|
||||||
|
in the document; packaging a file does not grant permission to redistribute it.
|
||||||
|
|
||||||
|
## Bundle format and limits
|
||||||
|
|
||||||
|
A bundle is uncompressed JSON with these fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"format": "gev-scene-bundle",
|
||||||
|
"version": 1,
|
||||||
|
"project": { "version": 6, "scenes": [] },
|
||||||
|
"assets": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each asset contains `path`, `mimeType`, `base64` and lowercase hexadecimal `sha256`.
|
||||||
|
Export copies the document and rewrites pack references to the reserved
|
||||||
|
`scene-bundle` source, adding exact `byteLength` and `sha256`. Paths are confined
|
||||||
|
relative names; URLs, traversal, duplicate or unreferenced assets, unknown fields,
|
||||||
|
missing packs and mismatched hashes are rejected. Scene documents remain bounded
|
||||||
|
at 5 MiB. A bundle allows 50 MiB of JSON, 64 files, 8 MiB per file and 32 MiB of
|
||||||
|
asset bytes in total. Supported MIME types are JSON/GeoJSON, PNG, MP4/WebM video
|
||||||
|
and MPEG/Ogg/WAV/WebM audio. Normal pack format and geometry checks still apply.
|
||||||
|
There are no executable modules, request headers or credentials in the format.
|
||||||
|
|
||||||
|
Imported bytes stay in memory for the current project and session. Stop releases
|
||||||
|
rendered resources while retaining these bytes for replay. Project replacement
|
||||||
|
or application teardown releases them. Only scene JSON is saved in browser
|
||||||
|
storage: **reimport the bundle after reloading the app**. Missing bundle bytes
|
||||||
|
fail explicitly, with no network fallback. This is file sharing, not a storage
|
||||||
|
service or an offline basemap.
|
||||||
|
|
||||||
|
Portable helpers are exported through `gods-eye-view/director`: `parseSceneShare`,
|
||||||
|
`readSceneShare`, `createSceneBundle`, `createBundleAssets`, `describeSceneShare`,
|
||||||
|
`editSceneDetails` and `selectSceneDocument`. Loading and UI remain separate owners.
|
||||||
|
`getSharingState()` reports copied dialog/asset counts for lifecycle diagnostics.
|
||||||
|
|
||||||
|
Run `node scripts/qa-director-sharing.mjs` against the local server for installed
|
||||||
|
controls, cancellation, stale drafts, export/import round trips, resource cleanup
|
||||||
|
and narrow-screen acceptance. Fixtures are synthetic and carry their own notices.
|
||||||
+17
-18
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Director is the scene playback system. Ordered shot execution, authored timing and playback clocks are separated
|
Director is the scene playback system. Ordered shot execution, authored timing and playback clocks are separated
|
||||||
from the scene editor and application rendering. This document
|
from the scene editor and application rendering. This document
|
||||||
also records the proposed next steps; the sharing format and interactions below
|
records implemented camera directions, data packs, declarative interactions and
|
||||||
are a design plan, not features already available.
|
file sharing, along with the remaining extensions.
|
||||||
|
|
||||||
## Implemented boundary
|
## Implemented boundary
|
||||||
|
|
||||||
@@ -76,8 +76,8 @@ and river coordinates are bundled. See the
|
|||||||
[Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit
|
[Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit
|
||||||
start/destination poses, easing, duration and holds. Playback and seek share
|
start/destination poses, easing, duration and holds. Playback and seek share
|
||||||
the same sampler. Ellipsoidal heights are explicit; terrain-relative input
|
the same sampler. Ellipsoidal heights are explicit; terrain-relative input
|
||||||
is rejected. Existing shots retain ordinary flights. Curved paths, look-at
|
is rejected. Existing shots retain ordinary flights. Basic anchor/move authoring is available through EDIT DETAILS; curved paths and
|
||||||
targets and visual authoring are later extensions.
|
look-at targets remain later extensions.
|
||||||
4. **Data packs and placement — implemented for bounded assets.**
|
4. **Data packs and placement — implemented for bounded assets.**
|
||||||
[Version 5](DIRECTOR-DATA-PACKS.md) defines a pack manifest with format/version,
|
[Version 5](DIRECTOR-DATA-PACKS.md) defines a pack manifest with format/version,
|
||||||
source, attribution/license and optional integrity/size metadata. It distinguishes
|
source, attribution/license and optional integrity/size metadata. It distinguishes
|
||||||
@@ -85,20 +85,19 @@ and river coordinates are bundled. See the
|
|||||||
media anchors. GeoJSON, PNG and native media adapters load with byte/time
|
media anchors. GeoJSON, PNG and native media adapters load with byte/time
|
||||||
limits and explicit ellipsoid heights. Stop and replacement release resources.
|
limits and explicit ellipsoid heights. Stop and replacement release resources.
|
||||||
Paths resolve against a trusted directory base; missing/offline assets fail
|
Paths resolve against a trusted directory base; missing/offline assets fail
|
||||||
explicitly. Bundles, transforms and terrain-relative placement remain future
|
explicitly. File bundles are available; transforms and terrain-relative placement
|
||||||
extensions. Scene files carry no credentials or executable modules.
|
remain future extensions. Scene files carry no credentials or executable modules.
|
||||||
5. **Declarative interactions.** Give pickable features stable IDs and connect
|
5. **Declarative interactions — implemented.** [Version 6](DIRECTOR-INTERACTIONS.md)
|
||||||
clicks to a small action registry: show a text/source card, focus an anchor,
|
connects selected feature IDs to accessible text/source cards, anchor focus,
|
||||||
seek a shot, or change an allowed layer state. Define selection feedback,
|
shot seek and admitted layer changes. References are validated and pending
|
||||||
keyboard equivalents and precedence with drawing/tracking controls. Validate
|
actions cancel on Stop, replacement or teardown. Scene files contain no
|
||||||
target references and cancel pending actions on Stop, replacement or teardown.
|
JavaScript or arbitrary HTML; actions require explicit user input.
|
||||||
No JavaScript evaluation or arbitrary HTML in scene files. Branching must
|
6. **Sharing and basic authoring — implemented.** [Authoring and sharing](DIRECTOR-SHARING.md)
|
||||||
have explicit loop/transition limits and reproducible reset/seek semantics.
|
adds import preview, missing-source/layer diagnostics, validated scene/shot
|
||||||
6. **Sharing and authoring.** Add validated preview/import/export, missing-pack
|
drafts and selected-scene JSON or explicit-file bundles. Pack attribution is
|
||||||
diagnostics and an optional self-contained bundle. Preserve attribution with
|
preserved. Bundle bytes last for the session and must be reimported after a
|
||||||
each pack; a share link does not change an asset's license. A future node graph
|
reload. A future node graph can author the same document without becoming a
|
||||||
could author this same document, but playback and the format will not require
|
playback or format requirement.
|
||||||
a graph editor.
|
|
||||||
|
|
||||||
Each step must preserve existing scene editing, keyboard playback, camera
|
Each step must preserve existing scene editing, keyboard playback, camera
|
||||||
arbitration, layer cleanup, media timing and provider fallback. Acceptance includes
|
arbitration, layer cleanup, media timing and provider fallback. Acceptance includes
|
||||||
|
|||||||
@@ -741,5 +741,7 @@
|
|||||||
"scripts/qa-director-packs.mjs",
|
"scripts/qa-director-packs.mjs",
|
||||||
"src/scenes/dataPacks/presentation.test.mjs",
|
"src/scenes/dataPacks/presentation.test.mjs",
|
||||||
"src/director/interactions/interactions.test.mjs",
|
"src/director/interactions/interactions.test.mjs",
|
||||||
"scripts/qa-director-interactions.mjs"
|
"scripts/qa-director-interactions.mjs",
|
||||||
|
"src/director/sharing/sharing.test.mjs",
|
||||||
|
"scripts/qa-director-sharing.mjs"
|
||||||
]
|
]
|
||||||
@@ -627,7 +627,11 @@
|
|||||||
"src/director/packs/geojson.js",
|
"src/director/packs/geojson.js",
|
||||||
"src/director/packs/manifest.js",
|
"src/director/packs/manifest.js",
|
||||||
"src/director/interactions/document.js",
|
"src/director/interactions/document.js",
|
||||||
"src/director/interactions/session.js"
|
"src/director/interactions/session.js",
|
||||||
|
"src/director/sharing/bundle.js",
|
||||||
|
"src/director/sharing/preview.js",
|
||||||
|
"src/director/sharing/lifetime.js",
|
||||||
|
"src/director/authoring.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -672,7 +676,13 @@
|
|||||||
"src/director/interactions/document.js",
|
"src/director/interactions/document.js",
|
||||||
"src/director/interactions/session.js",
|
"src/director/interactions/session.js",
|
||||||
"src/scenes/interactions.js",
|
"src/scenes/interactions.js",
|
||||||
"src/data/inputOwnership.js"
|
"src/data/inputOwnership.js",
|
||||||
|
"src/director/sharing/bundle.js",
|
||||||
|
"src/director/sharing/preview.js",
|
||||||
|
"src/director/sharing/lifetime.js",
|
||||||
|
"src/director/authoring.js",
|
||||||
|
"src/scenes/sharing.js",
|
||||||
|
"src/ui/sceneSharing.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1793,7 +1803,13 @@
|
|||||||
"src/scenes/dataPacks/geometry.js",
|
"src/scenes/dataPacks/geometry.js",
|
||||||
"src/director/interactions/document.js",
|
"src/director/interactions/document.js",
|
||||||
"src/director/interactions/session.js",
|
"src/director/interactions/session.js",
|
||||||
"src/scenes/interactions.js"
|
"src/scenes/interactions.js",
|
||||||
|
"src/director/sharing/bundle.js",
|
||||||
|
"src/director/sharing/preview.js",
|
||||||
|
"src/director/sharing/lifetime.js",
|
||||||
|
"src/director/authoring.js",
|
||||||
|
"src/scenes/sharing.js",
|
||||||
|
"src/ui/sceneSharing.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Sharing/authoring acceptance with local synthetic assets and real installed controls. */
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
const output = path.resolve('qa-shots/director-sharing');
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const geometry = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
id: 'point',
|
||||||
|
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 500] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(output, 'data.geojson'), JSON.stringify(geometry));
|
||||||
|
const camera = {
|
||||||
|
lat: 30.2672,
|
||||||
|
lon: -97.742,
|
||||||
|
alt: 4000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -90,
|
||||||
|
roll: 0,
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
version: 6,
|
||||||
|
scenes: [
|
||||||
|
{
|
||||||
|
id: 'share',
|
||||||
|
title: 'Sharing fixture',
|
||||||
|
dataPacks: [
|
||||||
|
{
|
||||||
|
id: 'data',
|
||||||
|
version: 1,
|
||||||
|
format: 'geojson',
|
||||||
|
source: { adapter: 'assets', path: 'example/data.geojson' },
|
||||||
|
attribution: { text: 'Synthetic <b>fixture</b>', license: 'CC0-1.0' },
|
||||||
|
placement: { altitudeReference: 'ellipsoid' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shots: [
|
||||||
|
{
|
||||||
|
id: 'shot',
|
||||||
|
title: 'First',
|
||||||
|
camera,
|
||||||
|
durationSec: 0.3,
|
||||||
|
holdSec: 0,
|
||||||
|
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||||
|
layers: {},
|
||||||
|
dataPackIds: ['data'],
|
||||||
|
interactions: [
|
||||||
|
{
|
||||||
|
id: 'info',
|
||||||
|
label: 'Read fixture',
|
||||||
|
target: { packId: 'data', featureId: 'point' },
|
||||||
|
action: { type: 'card', text: 'Synthetic scene note' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'second', title: 'Other scene', shots: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const file = path.join(output, 'project.json');
|
||||||
|
fs.writeFileSync(file, JSON.stringify(project));
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
let failures = 0;
|
||||||
|
const errors = [],
|
||||||
|
requests = [];
|
||||||
|
const check = (name, ok) => {
|
||||||
|
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!ok) failures++;
|
||||||
|
};
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
page.on('request', (r) => {
|
||||||
|
if (new URL(r.url()).pathname.startsWith('/scene-assets/'))
|
||||||
|
requests.push(r.url());
|
||||||
|
});
|
||||||
|
async function click(label) {
|
||||||
|
const handle = await page.evaluateHandle(
|
||||||
|
(label) =>
|
||||||
|
[...document.querySelectorAll('button')].find(
|
||||||
|
(b) => b.textContent === label,
|
||||||
|
),
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
await handle.asElement().click();
|
||||||
|
await handle.dispose();
|
||||||
|
}
|
||||||
|
async function value(label, text) {
|
||||||
|
await page.$eval(
|
||||||
|
`[aria-label="${label}"]`,
|
||||||
|
(node, text) => {
|
||||||
|
node.value = text;
|
||||||
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.sceneDirector &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.click('[data-collapse-target="scene-panel"]');
|
||||||
|
const before = await page.evaluate(() =>
|
||||||
|
JSON.stringify(window.__godsEyeView.sceneDirector._project),
|
||||||
|
);
|
||||||
|
const input = await page.$('#scene-import-file');
|
||||||
|
await input.uploadFile(file);
|
||||||
|
await page.waitForSelector('[data-director-apply-import]');
|
||||||
|
check(
|
||||||
|
'preview parses metadata without acquiring assets or replacing the current project',
|
||||||
|
requests.length === 0 &&
|
||||||
|
(await page.evaluate(
|
||||||
|
(before) =>
|
||||||
|
JSON.stringify(window.__godsEyeView.sceneDirector._project) ===
|
||||||
|
before,
|
||||||
|
before,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'preview renders author attribution as literal text',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-dialog]')
|
||||||
|
.textContent.includes('Synthetic <b>fixture</b>') &&
|
||||||
|
!document.querySelector('[data-director-dialog] b'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
check(
|
||||||
|
'Escape cancels preview without altering saved work',
|
||||||
|
await page.evaluate(
|
||||||
|
(before) =>
|
||||||
|
!document.querySelector('[data-director-dialog]') &&
|
||||||
|
JSON.stringify(window.__godsEyeView.sceneDirector._project) === before,
|
||||||
|
before,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await input.uploadFile(file);
|
||||||
|
await page.waitForSelector('[data-director-apply-import]');
|
||||||
|
await page.click('[data-director-apply-import]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('[data-director-dialog]'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Apply import replaces metadata but does not start playback or fetch a pack',
|
||||||
|
requests.length === 0 &&
|
||||||
|
(await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector._project.scenes.length === 2 &&
|
||||||
|
!window.__godsEyeView.sceneDirector.running,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
await click('EDIT DETAILS');
|
||||||
|
await click('Capture camera as anchor');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
.textContent.includes('Anchor added'),
|
||||||
|
);
|
||||||
|
await click('Set move start to current camera');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
.textContent.includes('Move added'),
|
||||||
|
);
|
||||||
|
const draft = await page.$eval(
|
||||||
|
'[aria-label="Shot camera, timing, packs and actions"]',
|
||||||
|
(n) => n.value,
|
||||||
|
);
|
||||||
|
const bad = JSON.parse(draft);
|
||||||
|
bad.camera.lat = 91;
|
||||||
|
await value('Shot camera, timing, packs and actions', JSON.stringify(bad));
|
||||||
|
const saved = await page.evaluate(() =>
|
||||||
|
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||||
|
);
|
||||||
|
await page.click('[data-director-apply-details]');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
.textContent.includes('camera.lat'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'invalid draft preserves saved bytes and existing authored content',
|
||||||
|
await page.evaluate(
|
||||||
|
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
|
||||||
|
saved,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await value('Shot camera, timing, packs and actions', draft);
|
||||||
|
await page.click('[data-director-apply-details]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('[data-director-dialog]'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'author controls preserve selected shot, pack attribution and action definitions',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector,
|
||||||
|
s = d._getSelectedScene();
|
||||||
|
return (
|
||||||
|
d._selectedShotId === 'shot' &&
|
||||||
|
s.anchors.length === 1 &&
|
||||||
|
s.shots[0].move.easing === 'cubic-in-out' &&
|
||||||
|
s.shots[0].interactions.length === 1 &&
|
||||||
|
s.dataPacks[0].attribution.license === 'CC0-1.0'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__shareExports = [];
|
||||||
|
const create = URL.createObjectURL.bind(URL);
|
||||||
|
URL.createObjectURL = (blob) => {
|
||||||
|
if (blob.type === 'application/json')
|
||||||
|
window.__shareExports.push(blob.text());
|
||||||
|
return create(blob);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await click('SHARE SCENE');
|
||||||
|
await click('Download scene JSON');
|
||||||
|
const exported = await page.evaluate(async () =>
|
||||||
|
JSON.parse(await window.__shareExports.at(-1)),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'scene JSON exports only the selected scene and keeps its credit',
|
||||||
|
exported.scenes.length === 1 &&
|
||||||
|
exported.scenes[0].dataPacks[0].attribution.license === 'CC0-1.0',
|
||||||
|
);
|
||||||
|
await click('Download asset bundle');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
.textContent.includes('Could not complete'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'missing explicit files produce no partial download or network fallback',
|
||||||
|
requests.length === 0 &&
|
||||||
|
(await page.evaluate(() => window.__shareExports.length === 1)),
|
||||||
|
);
|
||||||
|
const files = await page.$('[aria-label="Choose data-pack files"]');
|
||||||
|
await files.uploadFile(path.join(output, 'data.geojson'));
|
||||||
|
await click('Download asset bundle');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
.textContent === 'Asset bundle downloaded',
|
||||||
|
);
|
||||||
|
const bundle = await page.evaluate(
|
||||||
|
async () => await window.__shareExports.at(-1),
|
||||||
|
);
|
||||||
|
const bundleFile = path.join(output, 'scene.gevbundle.json');
|
||||||
|
fs.writeFileSync(bundleFile, bundle);
|
||||||
|
check(
|
||||||
|
'bundle contains the selected bytes and preserves attribution',
|
||||||
|
JSON.parse(bundle).assets.length === 1 &&
|
||||||
|
JSON.parse(bundle).project.scenes[0].dataPacks[0].attribution.license ===
|
||||||
|
'CC0-1.0',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await input.uploadFile(bundleFile);
|
||||||
|
await page.waitForSelector('[data-director-apply-import]');
|
||||||
|
check(
|
||||||
|
'bundle preview verifies bytes and explains session-only storage',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-dialog]')
|
||||||
|
.textContent.includes('bundled bytes verified') &&
|
||||||
|
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, 'preview.png') });
|
||||||
|
await page.click('[data-director-apply-import]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('[data-director-dialog]'),
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.sceneDirector.loadShot('share', 'shot', {
|
||||||
|
flyDuration: 0.3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'imported bundle renders and enables feature actions with zero asset requests',
|
||||||
|
requests.length === 0 &&
|
||||||
|
(await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getDataPackState().count === 1 &&
|
||||||
|
window.__godsEyeView.sceneDirector.getInteractionState().count === 1,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, 'rendered.png') });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(output, 'report.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
|
||||||
|
check(
|
||||||
|
'Stop releases rendered resources while retaining reusable project bytes',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
return (
|
||||||
|
d.getDataPackState().count === 0 &&
|
||||||
|
d.getSharingState().assets.count === 1
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.sceneDirector.seekScene('share', 0),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'seek reloads the bundle without a network fallback',
|
||||||
|
requests.length === 0 &&
|
||||||
|
(await page.evaluate(
|
||||||
|
() => window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
await click('EDIT DETAILS');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__godsEyeView.sceneDirector._project.scenes[0].title = 'Newer edit';
|
||||||
|
});
|
||||||
|
await page.click('[data-director-apply-details]');
|
||||||
|
check(
|
||||||
|
'stale author draft cannot overwrite a newer edit',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector._project.scenes[0].title ===
|
||||||
|
'Newer edit',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await click('SHARE SCENE');
|
||||||
|
await page.setViewport({ width: 390, height: 844 });
|
||||||
|
check(
|
||||||
|
'modal remains within a narrow viewport',
|
||||||
|
await page.$eval('[data-director-dialog]', (n) => {
|
||||||
|
const r = n.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
r.left >= 0 &&
|
||||||
|
r.right <= innerWidth &&
|
||||||
|
r.top >= 0 &&
|
||||||
|
r.bottom <= innerHeight
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, 'narrow.png') });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const pending = await page.evaluate(async () => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
let finish;
|
||||||
|
const work = d._sharing.preview({
|
||||||
|
name: 'pending.json',
|
||||||
|
text: () => new Promise((r) => (finish = r)),
|
||||||
|
});
|
||||||
|
d._sharing.close();
|
||||||
|
await work;
|
||||||
|
finish(JSON.stringify({ version: 6, scenes: [] }));
|
||||||
|
return !d.getSharingState().open;
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'cancel settles a pending file read and suppresses its late completion',
|
||||||
|
pending,
|
||||||
|
);
|
||||||
|
const cancelledApply = await page.evaluate(async () => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
const before = JSON.stringify(d._project);
|
||||||
|
const saved = JSON.stringify({ ...localStorage });
|
||||||
|
let finish;
|
||||||
|
const pendingWork = new Promise((resolve) => (finish = resolve));
|
||||||
|
d._pendingWork.add(pendingWork);
|
||||||
|
const owner = new AbortController();
|
||||||
|
const work = d.importProjectFile(
|
||||||
|
{ name: 'cancelled.json' },
|
||||||
|
{
|
||||||
|
prepared: { project: { version: 6, scenes: [] }, assets: new Map() },
|
||||||
|
expectedProject: JSON.stringify(d._project, null, 2),
|
||||||
|
signal: owner.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
owner.abort();
|
||||||
|
finish();
|
||||||
|
const applied = await work;
|
||||||
|
d._pendingWork.delete(pendingWork);
|
||||||
|
return (
|
||||||
|
applied === false &&
|
||||||
|
JSON.stringify(d._project) === before &&
|
||||||
|
JSON.stringify({ ...localStorage }) === saved &&
|
||||||
|
d.getSharingState().assets.count === 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'cancelling Apply during pending scene cleanup preserves saved project and bundle bytes',
|
||||||
|
cancelledApply,
|
||||||
|
);
|
||||||
|
await click('EDIT DETAILS');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const fields = [
|
||||||
|
...document.querySelectorAll('[data-director-dialog] textarea'),
|
||||||
|
];
|
||||||
|
const scene = JSON.parse(fields[0].value),
|
||||||
|
shot = JSON.parse(fields[1].value);
|
||||||
|
scene.dataPacks = [];
|
||||||
|
shot.dataPackIds = [];
|
||||||
|
shot.interactions = [];
|
||||||
|
fields[0].value = JSON.stringify(scene);
|
||||||
|
fields[1].value = JSON.stringify(shot);
|
||||||
|
});
|
||||||
|
await page.click('[data-director-apply-details]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('[data-director-dialog]'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'removing a pack through authoring releases its retained bytes',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
(text) =>
|
||||||
|
window.__godsEyeView.sceneDirector.importProjectFile(
|
||||||
|
new File([text], 'again.gevbundle.json'),
|
||||||
|
),
|
||||||
|
bundle,
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.sceneDirector.importProjectFile(
|
||||||
|
new File([JSON.stringify({ version: 6, scenes: [] })], 'empty.json'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'replacement releases stored bundle bytes',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
void d._sharing.preview({
|
||||||
|
name: 'pending.json',
|
||||||
|
text: () => new Promise(() => {}),
|
||||||
|
});
|
||||||
|
await d.destroy();
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'teardown removes pending dialogs and authoring controls',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
!document.querySelector('[data-director-dialog]') &&
|
||||||
|
!document.querySelector('[data-director-authoring]') &&
|
||||||
|
!window.__godsEyeView.sceneDirector.getSharingState().open,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check('no uncaught browser errors', errors.length === 0);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -154,7 +154,20 @@ try {
|
|||||||
const projectFile = path.join(shots, 'project.json');
|
const projectFile = path.join(shots, 'project.json');
|
||||||
fs.writeFileSync(projectFile, JSON.stringify(fixture));
|
fs.writeFileSync(projectFile, JSON.stringify(fixture));
|
||||||
const input = await page.$('#scene-import-file');
|
const input = await page.$('#scene-import-file');
|
||||||
|
const beforePreview = await page.evaluate(() =>
|
||||||
|
JSON.stringify(window.__godsEyeView.sceneDirector._project),
|
||||||
|
);
|
||||||
await input.uploadFile(projectFile);
|
await input.uploadFile(projectFile);
|
||||||
|
await page.waitForSelector('[data-director-apply-import]');
|
||||||
|
check(
|
||||||
|
'Import preview leaves the current project untouched',
|
||||||
|
await page.evaluate(
|
||||||
|
(saved) =>
|
||||||
|
JSON.stringify(window.__godsEyeView.sceneDirector._project) === saved,
|
||||||
|
beforePreview,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.click('[data-director-apply-import]');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
document.getElementById('scene-status').textContent ===
|
document.getElementById('scene-status').textContent ===
|
||||||
@@ -262,8 +275,8 @@ try {
|
|||||||
await input.uploadFile(badFile);
|
await input.uploadFile(badFile);
|
||||||
await page.waitForFunction(() =>
|
await page.waitForFunction(() =>
|
||||||
document
|
document
|
||||||
.getElementById('scene-status')
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
.textContent.includes('invalid JSON'),
|
?.textContent.includes('invalid JSON'),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'Invalid import reports failure and preserves the current project',
|
'Invalid import reports failure and preserves the current project',
|
||||||
@@ -281,7 +294,9 @@ try {
|
|||||||
fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] }));
|
fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] }));
|
||||||
await input.uploadFile(futureFile);
|
await input.uploadFile(futureFile);
|
||||||
await page.waitForFunction(() =>
|
await page.waitForFunction(() =>
|
||||||
document.getElementById('scene-status').textContent.includes('$.version'),
|
document
|
||||||
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
?.textContent.includes('$.version'),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'Unsupported versions leave authored state and saved bytes unchanged',
|
'Unsupported versions leave authored state and saved bytes unchanged',
|
||||||
@@ -299,7 +314,9 @@ try {
|
|||||||
fs.writeFileSync(malformedFile, JSON.stringify(malformed));
|
fs.writeFileSync(malformedFile, JSON.stringify(malformed));
|
||||||
await input.uploadFile(malformedFile);
|
await input.uploadFile(malformedFile);
|
||||||
await page.waitForFunction(() =>
|
await page.waitForFunction(() =>
|
||||||
document.getElementById('scene-status').textContent.includes('camera.lat'),
|
document
|
||||||
|
.querySelector('[data-director-dialog] [role=status]')
|
||||||
|
?.textContent.includes('camera.lat'),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'Invalid camera field identifies its path without replacing the project',
|
'Invalid camera field identifies its path without replacing the project',
|
||||||
@@ -308,6 +325,7 @@ try {
|
|||||||
savedBefore,
|
savedBefore,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||||
await page.setViewport({ width: 390, height: 844 });
|
await page.setViewport({ width: 390, height: 844 });
|
||||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||||
|
|||||||
@@ -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 { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
|
||||||
|
|
||||||
export { createInteractionSession } from './interactions/session.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 {
|
return {
|
||||||
...session,
|
...session,
|
||||||
|
sourceIds: () => Object.keys(sources),
|
||||||
getTargets: () => new Map(targets),
|
getTargets: () => new Map(targets),
|
||||||
apply(scene, shot, token) {
|
apply(scene, shot, token) {
|
||||||
const wanted = new Set(shot.dataPackIds || []);
|
const wanted = new Set(shot.dataPackIds || []);
|
||||||
|
|||||||
+73
-12
@@ -27,6 +27,12 @@ import {
|
|||||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||||
import { createSceneInteractions } from './interactions.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 { createDefaultScenePacks } from './packs/defaults.js';
|
||||||
import {
|
import {
|
||||||
layerStatesForShot,
|
layerStatesForShot,
|
||||||
@@ -51,7 +57,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
parseSceneDocument,
|
parseSceneDocument,
|
||||||
stringifySceneDocument,
|
stringifySceneDocument,
|
||||||
SCENE_DOCUMENT_LIMITS,
|
|
||||||
SceneDocumentError,
|
SceneDocumentError,
|
||||||
} from '../director/document.js';
|
} from '../director/document.js';
|
||||||
|
|
||||||
@@ -89,7 +94,15 @@ export class SceneDirector {
|
|||||||
this.dataManager = dataManager;
|
this.dataManager = dataManager;
|
||||||
this._isMapStackAvailable = isMapStackAvailable;
|
this._isMapStackAvailable = isMapStackAvailable;
|
||||||
this._scenePacks = scenePacks;
|
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._interactionTransitions = 0;
|
||||||
this._interactions = createSceneInteractions(viewer, {
|
this._interactions = createSceneInteractions(viewer, {
|
||||||
available: () =>
|
available: () =>
|
||||||
@@ -217,6 +230,8 @@ export class SceneDirector {
|
|||||||
this._cameraHandoffUnsubscribe?.();
|
this._cameraHandoffUnsubscribe?.();
|
||||||
this._removeCameraInput?.();
|
this._removeCameraInput?.();
|
||||||
this._cameraMotion?.destroy();
|
this._cameraMotion?.destroy();
|
||||||
|
this._sharing?.destroy();
|
||||||
|
this._bundleAssets?.clear();
|
||||||
this._interactions?.destroy();
|
this._interactions?.destroy();
|
||||||
this._dataPacks?.destroy();
|
this._dataPacks?.destroy();
|
||||||
this._controls?.destroy();
|
this._controls?.destroy();
|
||||||
@@ -369,6 +384,7 @@ export class SceneDirector {
|
|||||||
* Exits silently if the scene-select element is missing (headless/test mode).
|
* Exits silently if the scene-select element is missing (headless/test mode).
|
||||||
*/
|
*/
|
||||||
_initUI() {
|
_initUI() {
|
||||||
|
this._sharing?.mount();
|
||||||
this._controls = new SceneControls({
|
this._controls = new SceneControls({
|
||||||
subscribe: (listener) => this.subscribe(listener),
|
subscribe: (listener) => this.subscribe(listener),
|
||||||
read: () => ({
|
read: () => ({
|
||||||
@@ -404,6 +420,7 @@ export class SceneDirector {
|
|||||||
next: () => this.runNextScene(),
|
next: () => this.runNextScene(),
|
||||||
export: () => this.exportProject(),
|
export: () => this.exportProject(),
|
||||||
import: (file) => this.importProjectFile(file),
|
import: (file) => this.importProjectFile(file),
|
||||||
|
reviewImport: (file) => this._sharing.preview(file),
|
||||||
download: () => this.downloadLastRunMetadata(),
|
download: () => this.downloadLastRunMetadata(),
|
||||||
load: (sceneId, shotId) => this.loadShot(sceneId, shotId),
|
load: (sceneId, shotId) => this.loadShot(sceneId, shotId),
|
||||||
deleteShot: (sceneId, shotId) => this.deleteShot(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. */
|
/** Export the entire project as a timestamped JSON file download. */
|
||||||
exportProject() {
|
exportProject() {
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
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.
|
* The file is normalized/migrated on load; invalid JSON shows an error status.
|
||||||
* @param {File} file - Browser File object from an <input type="file">
|
* @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 =
|
const generation = (this._importGeneration =
|
||||||
(this._importGeneration || 0) + 1);
|
(this._importGeneration || 0) + 1);
|
||||||
try {
|
try {
|
||||||
if (file.size > SCENE_DOCUMENT_LIMITS.bytes) {
|
if (!prepared) this._sharing?.close();
|
||||||
throw new SceneDocumentError('$', 'file exceeds 5 MiB');
|
const input = prepared || (await readSceneShare(file, { signal }));
|
||||||
}
|
if (signal?.aborted) return false;
|
||||||
const text = await file.text();
|
if (this._destroyed || generation !== this._importGeneration)
|
||||||
if (this._destroyed || generation !== this._importGeneration) return;
|
return false;
|
||||||
const project = normalizeProject(parseSceneDocument(text));
|
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.
|
// Validate before touching playback, selection or the saved project.
|
||||||
this.stopScene('Importing project');
|
this.stopScene('Importing project');
|
||||||
await Promise.allSettled([...(this._pendingWork || [])]);
|
await Promise.allSettled([...(this._pendingWork || [])]);
|
||||||
if (this._destroyed || generation !== this._importGeneration) return;
|
if (this._destroyed || generation !== this._importGeneration) return;
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
(expectedProject &&
|
||||||
|
expectedProject !== JSON.stringify(this._project, null, 2))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
this._project = project;
|
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._storageReadError = null;
|
||||||
this._selectedSceneId = project.scenes[0]?.id || null;
|
this._selectedSceneId =
|
||||||
this._selectedShotId = project.scenes[0]?.shots[0]?.id || null;
|
selection?.sceneId || project.scenes[0]?.id || null;
|
||||||
|
this._selectedShotId =
|
||||||
|
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
|
||||||
this._loadedSceneId = null;
|
this._loadedSceneId = null;
|
||||||
this._saveProject();
|
this._saveProject();
|
||||||
this._publish({ type: 'project-imported', project });
|
this._publish({ type: 'project-imported', project });
|
||||||
this._updateStatus(`Imported ${file.name}`);
|
this._updateStatus(`Imported ${file.name}`);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this._destroyed || generation !== this._importGeneration) return;
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
this._destroyed ||
|
||||||
|
generation !== this._importGeneration
|
||||||
|
)
|
||||||
|
return false;
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
error instanceof SceneDocumentError
|
error instanceof SceneDocumentError
|
||||||
? `Import failed: ${error.message}`
|
? `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 () => {
|
this.listen(elements.file, 'change', async () => {
|
||||||
const file = elements.file?.files?.[0];
|
const file = elements.file?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
await this.run('import', file);
|
await this.run(
|
||||||
|
this.actions.reviewImport ? 'reviewImport' : 'import',
|
||||||
|
file,
|
||||||
|
);
|
||||||
if (!this.destroyed) elements.file.value = '';
|
if (!this.destroyed) elements.file.value = '';
|
||||||
});
|
});
|
||||||
if (!subscribe) {
|
if (!subscribe) {
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ function fixture() {
|
|||||||
}
|
}
|
||||||
appendChild(element) {
|
appendChild(element) {
|
||||||
this.children.push(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) {
|
setAttribute(name, value) {
|
||||||
this[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 {
|
#param-slider-panel.collapsed .param-panel-header {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.director-sharing-dialog::backdrop {
|
||||||
|
background: rgba(0, 8, 14, 0.65);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user