mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(director): add authored camera directions and anchors
This commit is contained in:
1 parent
0ea10468ea
commit
13c0b8d1bc
24 files changed
+1104
-167
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -112,3 +112,9 @@ export {
|
||||
SCENE_DOCUMENT_VERSION,
|
||||
SCENE_DOCUMENT_LIMITS,
|
||||
} from './document.js';
|
||||
|
||||
export {
|
||||
resolveCameraPose,
|
||||
resolveCameraMove,
|
||||
sampleCameraMove,
|
||||
} from './camera.js';
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user