feat(director): add authored camera directions and anchors

This commit is contained in:
Sameh Khamis committed 2026-09-15 16:53:26 -07:00
1 parent 0ea10468ea
commit 13c0b8d1bc
24 files changed
+1104 -167

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
/** Portable camera poses and authored motion. Angles are degrees; heights are ellipsoidal meters. */
/** Resolve an inline pose or a scene-local geographic anchor without modifying authored state. */
export function resolveCameraPose(scene, camera) {
if (!camera) return null;
const position = camera.anchorId
? scene?.anchors?.find(({ id }) => id === camera.anchorId)
: camera;
if (!position) throw new Error('Camera anchor is unavailable');
return {
lat: position.lat,
lon: position.lon,
alt: position.alt,
heading: camera.heading ?? 0,
pitch: camera.pitch ?? -35,
roll: camera.roll ?? 0,
};
}
/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */
export function resolveCameraMove(scene, shot) {
if (!shot?.move) return null;
return {
from: resolveCameraPose(scene, shot.move.from),
to: resolveCameraPose(scene, shot.camera),
easing: shot.move.easing,
durationSec: shot.durationSec,
};
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
if (t === 0) return { ...move.from };
if (t === 1) return { ...move.to };
const eased =
move.easing === 'linear'
? t
: t < 0.5
? 4 * t ** 3
: 1 - (-2 * t + 2) ** 3 / 2;
const lerp = (a, b) => a + (b - a) * eased;
const angle = (a, b) =>
a + (((((b - a + 540) % 360) + 360) % 360) - 180) * eased;
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
roll: angle(from.roll, to.roll),
};
}
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from './document.js';
import { normalizeProject } from '../scenes/project.js';
import { resolveCameraMove, sampleCameraMove } from './camera.js';
import { sceneSeekState } from './timeline.js';
const fixture = () => ({
version: 4,
scenes: [
{
id: 'scene',
title: 'Cross the dateline',
anchors: [
{
id: 'west',
title: 'Start',
lat: 10,
lon: 179,
alt: 400,
altitudeReference: 'ellipsoid',
},
{
id: 'east',
title: 'End',
lat: 12,
lon: -179,
alt: 800,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Cross',
durationSec: 4,
holdSec: 2,
camera: { anchorId: 'east', heading: 10, pitch: 0, roll: 0 },
move: {
from: { anchorId: 'west', heading: 350, pitch: -40, roll: 0 },
easing: 'linear',
},
},
],
},
],
});
test('version 4 retains anchor identity, references and explicit move edits through normalization', () => {
const raw = fixture();
const project = normalizeProject(parseSceneDocument(JSON.stringify(raw)));
assert.deepEqual(project.scenes[0].anchors, raw.scenes[0].anchors);
assert.deepEqual(
project.scenes[0].shots[0].move,
raw.scenes[0].shots[0].move,
);
assert.deepEqual(
project.scenes[0].shots[0].camera,
raw.scenes[0].shots[0].camera,
);
assert.deepEqual(
normalizeProject(parseSceneDocument(stringifySceneDocument(project))),
project,
);
});
test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4', () => {
const project = normalizeProject(
parseSceneDocument(
JSON.stringify({
version: 3,
scenes: [
{ shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] },
],
}),
),
);
assert.equal(project.version, 4);
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
});
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture())))
.scenes[0];
const move = resolveCameraMove(scene, scene.shots[0]);
const midpoint = sampleCameraMove(move, 0.5);
assert.equal(midpoint.lon, -180);
assert.equal(midpoint.heading, 360);
assert.equal(midpoint.lat, 11);
assert.equal(midpoint.alt, 600);
assert.equal(midpoint.pitch, -20);
const seek = (p) =>
sceneSeekState(
scene,
p,
(_, shot) => shot.durationSec + shot.holdSec,
(_, shot) => shot.holdSec,
);
assert.deepEqual(seek(1 / 3).camera, midpoint);
assert.deepEqual(seek(0).camera, move.from);
assert.deepEqual(seek(5 / 6).camera, move.to);
assert.equal(seek(5 / 6).holdElapsedSec, 1);
assert.deepEqual(
seek(1 / 3).camera,
midpoint,
'backward seek is independent of previous viewport state',
);
assert.equal(
sampleCameraMove({ ...move, easing: 'cubic-in-out' }, 0.25).lat,
10.125,
);
assert.deepEqual(sampleCameraMove(move, 1), move.to);
});
test('unknown anchors, duplicate IDs, mixed references, bad easing and unspecified altitude are rejected', () => {
for (const mutate of [
(s) => {
s.shots[0].camera.anchorId = 'missing';
},
(s) => {
s.anchors[1].id = 'west';
},
(s) => {
s.anchors[0].altitudeReference = 'terrain';
},
(s) => {
delete s.anchors[0].altitudeReference;
},
(s) => {
s.shots[0].camera.lon = 1;
},
(s) => {
s.shots[0].move.easing = 'bounce';
},
(s) => {
s.shots[0].move.from = { lat: 1, lon: 2, alt: 3 };
},
(s) => {
s.shots[0].durationSec = 0;
},
(s) => {
delete s.shots[0].holdSec;
},
]) {
const project = fixture();
mutate(project.scenes[0]);
assert.throws(() => parseSceneDocument(JSON.stringify(project)), {
name: 'SceneDocumentError',
});
}
const old = fixture();
old.version = 3;
assert.throws(
() => parseSceneDocument(JSON.stringify(old)),
/unsupported field/,
);
});
+88
View File
@@ -0,0 +1,88 @@
import {
fields,
string,
number,
optional,
array,
fail,
uniqueId,
} from './documentFields.js';
const POSITION = { lat: [-90, 90], lon: [-180, 180], alt: [-12000, 1e9] };
const ORIENTATION = {
heading: [-360, 360],
pitch: [-90, 90],
roll: [-360, 360],
};
function coordinates(value, path, bounds, required, legacy) {
for (const [key, range] of Object.entries(bounds)) {
if (required || Object.hasOwn(value, key))
number(value[key], `${path}.${key}`, ...range, legacy);
}
}
function reference(value, path) {
if (value !== 'ellipsoid')
fail(
path,
'expected ellipsoid height in meters; terrain-relative heights are not supported',
);
}
function pose(value, path, version, anchorIds, explicit = false) {
const anchored = version >= 4 && Object.hasOwn(value || {}, 'anchorId');
fields(value, path, [
...Object.keys(ORIENTATION),
...(anchored
? ['anchorId']
: [
...Object.keys(POSITION),
...(version >= 4 ? ['altitudeReference'] : []),
]),
]);
coordinates(value, path, ORIENTATION, false, version < 3);
if (anchored) {
string(value.anchorId, `${path}.anchorId`);
if (!anchorIds.has(value.anchorId))
fail(`${path}.anchorId`, 'unknown scene anchor');
} else {
coordinates(value, path, POSITION, explicit, version < 3);
if (explicit || Object.hasOwn(value, 'altitudeReference'))
reference(value.altitudeReference, `${path}.altitudeReference`);
}
}
/** Validate scene-local anchor IDs, camera references and explicit motion without reading terrain or applying state. */
export function validateSceneCameras(scene, path, version) {
const anchorIds = new Set();
optional(scene, 'anchors', path, (anchors, field) => {
array(anchors, field, 1024);
anchors.forEach((anchor, i) => {
const at = `${field}[${i}]`;
fields(anchor, at, [
'id',
'title',
...Object.keys(POSITION),
'altitudeReference',
]);
string(anchor.id, `${at}.id`);
uniqueId(anchor, at, anchorIds);
optional(anchor, 'title', at, (v, p) => string(v, p, 4096));
coordinates(anchor, at, POSITION, true, false);
reference(anchor.altitudeReference, `${at}.altitudeReference`);
});
});
scene.shots.forEach((shot, index) => {
const at = `${path}.shots[${index}]`;
optional(shot, 'move', at, (move, field) => {
fields(move, field, ['from', 'easing']);
if (!['linear', 'cubic-in-out'].includes(move.easing))
fail(`${field}.easing`, 'expected linear or cubic-in-out');
pose(move.from, `${field}.from`, version, anchorIds, true);
pose(shot.camera, `${at}.camera`, version, anchorIds, true);
number(shot.durationSec, `${at}.durationSec`, 0.2, 86400, false);
number(shot.holdSec, `${at}.holdSec`, 0, 86400, false);
});
optional(shot, 'camera', at, (camera, field) =>
pose(camera, field, version, anchorIds),
);
});
}
+21 -110
View File
@@ -1,98 +1,21 @@
/** Bounded, renderer-independent validation for authored scene documents. */
export const SCENE_DOCUMENT_VERSION = 3;
export const SCENE_DOCUMENT_LIMITS = Object.freeze({
bytes: 5 * 1024 * 1024,
scenes: 256,
shots: 10000,
collection: 10000,
depth: 24,
nodes: 200000,
string: 65536,
});
import {
SCENE_DOCUMENT_LIMITS,
fail,
object,
fields,
string,
number,
optional,
array,
ids,
uniqueId,
jsonTree,
} from './documentFields.js';
import { validateSceneCameras } from './cameraDocument.js';
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
export const SCENE_DOCUMENT_VERSION = 4;
/** A project error identifies a field without echoing its supplied value. */
export class SceneDocumentError extends Error {
constructor(path, reason) {
super(`${path}: ${reason}`);
this.name = 'SceneDocumentError';
this.path = path;
}
}
const fail = (path, reason) => {
throw new SceneDocumentError(path, reason);
};
const object = (value, path) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
fail(path, 'expected an object');
};
function fields(value, path, allowed) {
object(value, path);
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field');
}
}
function string(value, path, max = 256) {
if (typeof value !== 'string' || !value.trim() || value.length > max)
fail(path, `expected nonempty text, at most ${max} characters`);
}
function number(value, path, min, max, legacy) {
const numeric =
legacy && typeof value === 'string' && value.trim() ? Number(value) : value;
if (
typeof numeric !== 'number' ||
!Number.isFinite(numeric) ||
numeric < min ||
numeric > max
)
fail(path, `expected a number from ${min} to ${max}`);
}
function optional(value, key, path, check) {
if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`);
}
function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) {
if (!Array.isArray(value) || value.length > max)
fail(path, `expected an array of at most ${max} entries`);
}
function ids(value, path) {
array(value, path);
value.forEach((id, index) => string(id, `${path}[${index}]`));
}
function uniqueId(value, path, seen) {
if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration.
string(value.id, `${path}.id`);
if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID');
seen.add(value.id);
}
function jsonTree(value, path, budget, depth = 0) {
if (
++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes ||
depth > SCENE_DOCUMENT_LIMITS.depth
)
fail(path, 'project complexity limit exceeded');
if (value === null || typeof value === 'boolean') return;
if (typeof value === 'string') {
if (value.length > SCENE_DOCUMENT_LIMITS.string)
fail(path, 'text is too long');
return;
}
if (typeof value === 'number' && Number.isFinite(value)) return;
if (!value || typeof value !== 'object') fail(path, 'expected a JSON value');
if (
Object.getPrototypeOf(value) !== Object.prototype &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== null
)
fail(path, 'expected a JSON object');
const entries = Object.entries(value);
if (entries.length > SCENE_DOCUMENT_LIMITS.collection)
fail(path, 'too many entries');
for (const [key, child] of entries) {
if (['__proto__', 'constructor', 'prototype'].includes(key))
fail(path, 'unsafe object key');
if (key.length > 256) fail(path, 'field name is too long');
jsonTree(child, `${path}.${key}`, budget, depth + 1);
}
}
function visual(value, path, legacy) {
fields(value, path, [
'style',
@@ -141,8 +64,8 @@ export function validateSceneDocument(project) {
'installedBuiltInSceneIds',
'scenes',
]);
const version = project.version ?? 1;
if (![1, 2, 3].includes(version))
const version = Object.hasOwn(project, 'version') ? project.version : 1;
if (![1, 2, 3, 4].includes(version))
fail('$.version', 'unsupported scene project version');
const legacy = version < 3;
for (const key of ['createdAt', 'updatedAt'])
@@ -159,6 +82,7 @@ export function validateSceneDocument(project) {
'releaseLayerIds',
'appliedShotPacks',
'shots',
...(version >= 4 ? ['anchors'] : []),
]);
uniqueId(scene, path, sceneIds);
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
@@ -198,6 +122,7 @@ export function validateSceneDocument(project) {
'layers',
'sourcePackId',
'sourcePackVersion',
...(version >= 4 ? ['move'] : []),
]);
uniqueId(shot, at, shotIds);
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
@@ -209,21 +134,6 @@ export function validateSceneDocument(project) {
number(v, p, 0, 86400, legacy),
);
optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy));
optional(shot, 'camera', at, (camera, field) => {
const bounds = {
lat: [-90, 90],
lon: [-180, 180],
alt: [-12000, 1e9],
heading: [-360, 360],
pitch: [-90, 90],
roll: [-360, 360],
};
fields(camera, field, Object.keys(bounds));
for (const [key, range] of Object.entries(bounds))
optional(camera, key, field, (v, p) =>
number(v, p, ...range, legacy),
);
});
optional(shot, 'visual', at, (v, p) => visual(v, p, legacy));
optional(shot, 'layers', at, (layers, field) => {
object(layers, field);
@@ -238,6 +148,7 @@ export function validateSceneDocument(project) {
}
});
});
validateSceneCameras(scene, path, version);
});
return project;
}
+1 -1
View File
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.version, 3);
assert.equal(migrated.version, 4);
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
assert.deepEqual(
+94
View File
@@ -0,0 +1,94 @@
/** Shared bounded JSON and field checks for scene formats. */
export const SCENE_DOCUMENT_LIMITS = Object.freeze({
bytes: 5 * 1024 * 1024,
scenes: 256,
shots: 10000,
collection: 10000,
depth: 24,
nodes: 200000,
string: 65536,
});
/** A project error identifies a field without echoing its supplied value. */
export class SceneDocumentError extends Error {
constructor(path, reason) {
super(`${path}: ${reason}`);
this.name = 'SceneDocumentError';
this.path = path;
}
}
export const fail = (path, reason) => {
throw new SceneDocumentError(path, reason);
};
export const object = (value, path) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
fail(path, 'expected an object');
};
export function fields(value, path, allowed) {
object(value, path);
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field');
}
}
export function string(value, path, max = 256) {
if (typeof value !== 'string' || !value.trim() || value.length > max)
fail(path, `expected nonempty text, at most ${max} characters`);
}
export function number(value, path, min, max, legacy) {
const numeric =
legacy && typeof value === 'string' && value.trim() ? Number(value) : value;
if (
typeof numeric !== 'number' ||
!Number.isFinite(numeric) ||
numeric < min ||
numeric > max
)
fail(path, `expected a number from ${min} to ${max}`);
}
export function optional(value, key, path, check) {
if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`);
}
export function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) {
if (!Array.isArray(value) || value.length > max)
fail(path, `expected an array of at most ${max} entries`);
}
export function ids(value, path) {
array(value, path);
value.forEach((id, index) => string(id, `${path}[${index}]`));
}
export function uniqueId(value, path, seen) {
if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration.
string(value.id, `${path}.id`);
if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID');
seen.add(value.id);
}
export function jsonTree(value, path, budget, depth = 0) {
if (
++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes ||
depth > SCENE_DOCUMENT_LIMITS.depth
)
fail(path, 'project complexity limit exceeded');
if (value === null || typeof value === 'boolean') return;
if (typeof value === 'string') {
if (value.length > SCENE_DOCUMENT_LIMITS.string)
fail(path, 'text is too long');
return;
}
if (typeof value === 'number' && Number.isFinite(value)) return;
if (!value || typeof value !== 'object') fail(path, 'expected a JSON value');
if (
Object.getPrototypeOf(value) !== Object.prototype &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== null
)
fail(path, 'expected a JSON object');
const entries = Object.entries(value);
if (entries.length > SCENE_DOCUMENT_LIMITS.collection)
fail(path, 'too many entries');
for (const [key, child] of entries) {
if (['__proto__', 'constructor', 'prototype'].includes(key))
fail(path, 'unsafe object key');
if (key.length > 256) fail(path, 'field name is too long');
jsonTree(child, `${path}.${key}`, budget, depth + 1);
}
}
+6
View File
@@ -112,3 +112,9 @@ export {
SCENE_DOCUMENT_VERSION,
SCENE_DOCUMENT_LIMITS,
} from './document.js';
export {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
+15 -2
View File
@@ -1,3 +1,9 @@
import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
@@ -78,7 +84,12 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
const holdProgress =
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera;
const targetCamera = resolveCameraPose(scene, shot.camera);
const previousCamera = resolveCameraPose(
scene,
scene.shots[shotIndex - 1]?.camera || shot.camera,
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
@@ -92,6 +103,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
holdElapsedSec,
cameraProgress,
holdProgress,
camera: cameraAtProgress(previousCamera, shot.camera, cameraProgress),
camera: move
? sampleCameraMove(move, cameraProgress)
: cameraAtProgress(previousCamera, targetCamera, cameraProgress),
};
}
+70
View File
@@ -0,0 +1,70 @@
import { sampleCameraMove } from '../director/camera.js';
/** Own one authored camera animation and settle it immediately on cancellation or replacement. */
export function createCameraMotion({
applyPose,
now = () => performance.now(),
requestFrame = (callback) => requestAnimationFrame(callback),
cancelFrame = (handle) => cancelAnimationFrame(handle),
} = {}) {
let active = null;
let disposed = false;
const cancel = () => active?.finish(false);
return {
get active() {
return active !== null;
},
cancel,
destroy() {
disposed = true;
cancel();
},
play(move, token) {
cancel();
if (disposed || token?.cancelled || token?.signal?.aborted)
return Promise.resolve(false);
if (!Number.isFinite(move.durationSec) || move.durationSec <= 0)
throw new RangeError('Camera move duration must be positive');
return new Promise((resolve, reject) => {
const run = { handle: null, finish: null };
const startedAt = now();
const onAbort = () => run.finish(false);
run.finish = (completed, error) => {
if (active !== run) return;
active = null;
if (run.handle !== null) cancelFrame(run.handle);
token?.signal?.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve(completed);
};
active = run;
token?.signal?.addEventListener('abort', onAbort, { once: true });
const tick = () => {
if (active !== run) return;
run.handle = null;
if (disposed || token?.cancelled || token?.signal?.aborted) {
run.finish(false);
return;
}
try {
const progress = Math.min(
1,
Math.max(0, (now() - startedAt) / (move.durationSec * 1000)),
);
if (applyPose(sampleCameraMove(move, progress)) === false) {
run.finish(false);
return;
}
// Applying a pose can synchronously transfer camera ownership.
if (active !== run) return;
if (progress >= 1) run.finish(true);
else run.handle = requestFrame(tick);
} catch (error) {
run.finish(false, error);
}
};
tick();
});
},
};
}
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCameraMotion } from './cameraMotion.js';
const move = {
durationSec: 2,
easing: 'linear',
from: { lat: 0, lon: 0, alt: 100, heading: 0, pitch: 0, roll: 0 },
to: { lat: 2, lon: 2, alt: 200, heading: 0, pitch: 0, roll: 0 },
};
function harness(apply) {
let time = 0;
let next = 0;
const frames = new Map();
const poses = [];
const owner = createCameraMotion({
now: () => time,
requestFrame: (cb) => {
const id = ++next;
frames.set(id, cb);
return id;
},
cancelFrame: (id) => frames.delete(id),
applyPose: (pose) => {
poses.push(pose);
return apply?.(pose);
},
});
return {
owner,
frames,
poses,
tick(ms) {
time = ms;
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((cb) => cb());
},
};
}
test('motion uses the authored curve and releases the frame on completion', async () => {
const h = harness();
const done = h.owner.play(move, { cancelled: false });
assert.deepEqual(h.poses[0], move.from);
h.tick(1000);
assert.equal(h.poses.at(-1).alt, 150);
h.tick(2000);
assert.equal(await done, true);
assert.deepEqual(h.poses.at(-1), move.to);
assert.equal(h.frames.size, 0);
assert.equal(h.owner.active, false);
});
test('abort and replacement settle immediately and revoke already queued callbacks', async () => {
const h = harness();
const abort = new AbortController();
const first = h.owner.play(move, { signal: abort.signal });
const stale = [...h.frames.values()][0];
abort.abort();
assert.equal(await first, false);
const second = h.owner.play(move, {});
const count = h.poses.length;
stale();
assert.equal(h.poses.length, count);
h.owner.destroy();
assert.equal(await second, false);
assert.equal(h.frames.size, 0);
assert.equal(await h.owner.play(move, {}), false);
});
test('synchronous cancellation from pose application schedules no abandoned frame', async () => {
let owner;
const h = harness(() => owner.cancel());
owner = h.owner;
assert.equal(await owner.play(move, {}), false);
assert.equal(h.frames.size, 0);
});
test('failed pose application settles and invalid durations cannot schedule a loop', async () => {
const h = harness(() => {
throw new Error('camera unavailable');
});
await assert.rejects(h.owner.play(move, {}), /camera unavailable/);
assert.equal(h.frames.size, 0);
assert.throws(
() => h.owner.play({ ...move, durationSec: 0 }, {}),
/duration/,
);
});
+71 -15
View File
@@ -12,6 +12,8 @@
* State is persisted to localStorage and can be exported/imported as JSON.
*/
import { resolveCameraPose, resolveCameraMove } from '../director/camera.js';
import { createCameraMotion } from './cameraMotion.js';
import { createStateChannel } from '../app/stateChannel.js';
import { SceneControls } from '../ui/scenes.js';
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
@@ -84,6 +86,22 @@ export class SceneDirector {
this.dataManager = dataManager;
this._isMapStackAvailable = isMapStackAvailable;
this._scenePacks = scenePacks;
this._cameraMotion = createCameraMotion({
applyPose: (pose) => this._setCameraView(pose),
});
const yieldCamera = () => {
if (this._usesAuthoredCamera && !this._claimingCamera)
this.stopScene('Camera ownership changed');
};
this._cameraHandoffUnsubscribe =
styleManager.subscribeCameraHandoff?.(yieldCamera);
const canvas = viewer.scene?.canvas;
for (const event of ['pointerdown', 'wheel'])
canvas?.addEventListener(event, yieldCamera, { passive: true });
this._removeCameraInput = () => {
for (const event of ['pointerdown', 'wheel'])
canvas?.removeEventListener(event, yieldCamera);
};
/** @type {boolean} True while a scene run is in progress */
this._running = false;
@@ -179,6 +197,9 @@ export class SceneDirector {
this._destroyed = true;
this._sceneSeekGeneration++;
this._visibilityUnsubscribe?.();
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
this._cameraMotion?.destroy();
this._controls?.destroy();
this._state.destroy();
this._destroyPromise = Promise.resolve().then(async () => {
@@ -903,7 +924,9 @@ export class SceneDirector {
const camera = this.styleManager.getCameraState();
if (!camera) return;
shot.camera = camera;
shot.camera = shot.move
? { ...camera, altitudeReference: 'ellipsoid' }
: camera;
shot.visual = this.styleManager.getVisualState();
shot.layers = this._captureLayerStates();
@@ -955,11 +978,12 @@ export class SceneDirector {
async _loadShot(
sceneId,
shotId,
{ flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {},
{ flyDuration = null, fromCamera = null, sceneSeek = null } = {},
) {
if (this._running) return { started: false, reason: 'already-running' };
const { scene, shot } = this._getShot(sceneId, shotId);
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
flyDuration ??= shot.move ? shot.durationSec : 2.2;
const previousScene =
this._project.scenes.find((item) => item.id === this._loadedSceneId) ||
null;
@@ -972,6 +996,7 @@ export class SceneDirector {
// means an in-flight layer transition is cancelled (and rolled back by the
// manager) rather than merely ignored once it has already committed.
this._cancelActiveSceneTravel();
this._usesAuthoredCamera = !!shot.move;
this._loadAbort?.abort();
const controller = new AbortController();
this._loadAbort = controller;
@@ -1017,7 +1042,9 @@ export class SceneDirector {
return { started: false, reason: 'layers-refused' };
}
if (seekState) {
this._setCameraView(seekState.camera || shot.camera);
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
);
this._setProgress(seekState.sceneProgress);
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
running: false,
@@ -1044,10 +1071,10 @@ export class SceneDirector {
);
const cameraTravel = this._beginShotTravel(scene, shot, flyDuration);
try {
// _flyCamera calls Cesium's flyTo synchronously before yielding. Publish
// the trail phase immediately afterward so its clock overlaps actual
// Camera motion starts synchronously before yielding. Publish the
// trail phase immediately afterward so its clock overlaps actual
// camera motion, never an earlier asynchronous layer-reconcile wait.
const flight = this._flyCamera(shot.camera, flyDuration, token);
const flight = this._flyShotCamera(scene, shot, flyDuration, token);
this._publishShotTravel(scene, shot, cameraTravel);
await flight;
} catch (error) {
@@ -1171,6 +1198,8 @@ export class SceneDirector {
/** Revoke layer-owned travel motion before a newer load, STOP, or teardown. */
_cancelActiveSceneTravel() {
this._cameraMotion?.cancel();
this._usesAuthoredCamera = false;
// The opening locator owns an additional delayed approach/orbit even after
// the director's authored flight has settled. Revoke it before cancelling
// the camera, whose moveEnd/complete callbacks may already be queued.
@@ -1242,7 +1271,9 @@ export class SceneDirector {
scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
const result = await this.loadShot(sceneId, shotId, {
flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
fromCamera: previousShot?.camera || null,
fromCamera: shot.move
? null
: resolveCameraPose(scene, previousShot?.camera),
});
return result || { started: false, reason: 'cancelled' };
}
@@ -1291,7 +1322,8 @@ export class SceneDirector {
/** Read timing diagnostics without exposing timer handles or mutable clock state. */
getPlaybackTimingState() {
return {
activeTimers: this._clock.activeTimers,
activeTimers:
this._clock.activeTimers + (this._cameraMotion?.active ? 1 : 0),
snapshot: this._clock.snapshot,
};
}
@@ -1345,7 +1377,9 @@ export class SceneDirector {
}
}
if (!applied) return false;
this._setCameraView(seekState.camera || shot.camera);
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
);
this._setProgress(seekState.sceneProgress);
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
running: false,
@@ -1419,10 +1453,13 @@ export class SceneDirector {
// Older/headless style managers may predate the facade — proceed then.
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
return true;
const claimed = this.styleManager.runImmediateNavigation(
'scene',
() => true,
);
let claimed;
this._claimingCamera = true;
try {
claimed = this.styleManager.runImmediateNavigation('scene', () => true);
} finally {
this._claimingCamera = false;
}
if (claimed === false) {
this._updateStatus('Camera unavailable — exit cockpit first');
return false;
@@ -1444,7 +1481,7 @@ export class SceneDirector {
),
orientation: {
heading: Cesium.Math.toRadians(cameraState.heading || 0),
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35),
roll: Cesium.Math.toRadians(cameraState.roll || 0),
},
});
@@ -1581,6 +1618,8 @@ export class SceneDirector {
this._loadGeneration++;
this._clock.stopShot();
this._usesAuthoredCamera = queue.some(({ shot }) => !!shot.move);
// Transition to running state
this._running = true;
this._previewRun = preview;
@@ -1957,6 +1996,23 @@ export class SceneDirector {
return true;
}
/** Use the authored sampler only for explicit moves; ordinary shots keep their existing flights. */
async _flyShotCamera(scene, shot, durationSec, token) {
const move = resolveCameraMove(scene, shot);
if (!move)
return this._flyCamera(
resolveCameraPose(scene, shot.camera),
durationSec,
token,
);
const completed = await this._cameraMotion.play(
{ ...move, durationSec },
token,
);
if (!completed && !token.cancelled && !this._destroyed)
this.stopScene('Camera move interrupted');
}
/**
* Fly the Cesium camera to the given position over the specified duration
* using cubic ease-in-out. Resolves when the flight completes, is cancelled,
@@ -1998,7 +2054,7 @@ export class SceneDirector {
destination,
orientation: {
heading: Cesium.Math.toRadians(cameraState.heading || 0),
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35),
roll: Cesium.Math.toRadians(cameraState.roll || 0),
},
duration,
+27 -1
View File
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
assert.equal(director._storageReadError, null);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 3);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 4);
} finally { restore(); }
});
@@ -1491,3 +1491,29 @@ test('invalid authored edits cannot persist an unreadable project over a good sa
assert.match(notice, /camera.lat/);
} finally { restore(); }
});
test('zero camera pitch is preserved by both immediate placement and ordinary flight', async () => {
const { director, viewer, restore } = makeDirector();
try {
let placed;
viewer.camera.setView = (options) => { placed = options; };
const pose = { lat: 10, lon: 20, alt: 500, heading: 0, pitch: 0, roll: 0 };
director._setCameraView(pose);
assert.equal(placed.orientation.pitch, 0);
await director._flyCamera(pose, 0.2, { cancelled: false });
assert.equal(viewer.flights.at(-1).orientation.pitch, 0);
} finally { restore(); }
});
test('camera refusal starts no authored frame or playback clock', async () => {
const { director, styleManager, viewer, restore } = makeDirector();
try {
const shot = director._project.scenes[0].shots[0];
shot.move = { from: { ...shot.camera, altitudeReference: 'ellipsoid' }, easing: 'linear' };
styleManager.runImmediateNavigation = () => false;
assert.equal((await director.startScene('scene-1', { single: true })).reason, 'camera-unavailable');
assert.equal(director._cameraMotion.active, false);
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
assert.deepEqual(viewer.flights, []);
} finally { restore(); }
});
+1 -1
View File
@@ -40,7 +40,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
const duration = shot.durationSec || defaultShotDurationSec;
timing = director._startSceneClockTicker(scene, shot, token);
travel = director._beginShotTravel(scene, shot, duration);
const flight = director._flyCamera(shot.camera, duration, token);
const flight = director._flyShotCamera(scene, shot, duration, token);
director._publishShotTravel(scene, shot, travel);
return flight;
},
+26 -10
View File
@@ -67,11 +67,11 @@ function normalizeBloomState(
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
) {
// Determine which bloom scale the stored value was encoded under.
// Older projects (version < PROJECT_VERSION) used scale version 1.
// Older projects (version < 3) used scale version 1.
const explicitVersion = Number(rawBloom.version);
const bloomVersion = Number.isFinite(explicitVersion)
? explicitVersion
: projectVersion >= PROJECT_VERSION
: projectVersion >= 3
? BLOOM_SCALE_VERSION
: 1;
@@ -231,14 +231,29 @@ export function normalizeShot(
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
),
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
camera: {
lat: Number(camera.lat) || 0,
lon: Number(camera.lon) || 0,
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
heading: Number(camera.heading) || 0,
pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35,
roll: Number(camera.roll) || 0,
},
camera: camera.anchorId
? {
anchorId: camera.anchorId,
heading: Number(camera.heading) || 0,
pitch: Number.isFinite(Number(camera.pitch))
? Number(camera.pitch)
: -35,
roll: Number(camera.roll) || 0,
}
: {
lat: Number(camera.lat) || 0,
lon: Number(camera.lon) || 0,
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
heading: Number(camera.heading) || 0,
pitch: Number.isFinite(Number(camera.pitch))
? Number(camera.pitch)
: -35,
roll: Number(camera.roll) || 0,
...(camera.altitudeReference || rawShot?.move
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
: {}),
},
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {
style: visual.style || 'normal',
bloom: normalizeBloomState(bloom, {
@@ -331,6 +346,7 @@ export function normalizeProject(rawProject) {
return {
id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`,
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
releaseLayerIds: [
...new Set(
(Array.isArray(scene?.releaseLayerIds)
+11
View File
@@ -1,3 +1,4 @@
import { createStateChannel } from '../app/stateChannel.js';
import * as Cesium from 'cesium';
import {
beginDeferredNavigation,
@@ -34,6 +35,9 @@ export class NavigationController {
cancelOrientation,
});
this._navigationGeneration = 0;
this._cameraHandoffs = createStateChannel(() => ({
generation: this._navigationGeneration,
}));
this._activeLocationSearchGeneration = null;
this._disposed = false;
}
@@ -45,6 +49,7 @@ export class NavigationController {
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
this.tracking;
this._navigationGeneration += 1;
this._cameraHandoffs?.publish();
// A newer destination owns the camera, so the last free-text search is no
// longer where we are. DEFERRED navigation opts out here and clears at the
// reassert seam instead: a geocode that never resolves moves no camera, and
@@ -247,8 +252,14 @@ export class NavigationController {
},
});
}
/** Subscribe to ownership changes without claiming the camera or exposing mutable state. */
subscribeCameraHandoff(listener) {
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
}
stop() {
this._disposed = true;
this._cameraHandoffs?.publish();
this._cameraHandoffs?.destroy();
}
destroy() {
this.stop();
+5
View File
@@ -4,6 +4,11 @@
* New logic belongs with an owner, not in this forwarding surface.
*/
export class ShellFacade {
/** Observe camera ownership transfers through the navigation owner. */
subscribeCameraHandoff(listener) {
return this._navigation.subscribeCameraHandoff(listener);
}
get _models3dModeBtns() {
return this._aircraftDisplay._models3dModeBtns;
}