Files
gods-eye-view/src/director/camera.test.mjs
T
Claude c330eb801a feat(director): recorded camera tracks and shot lens (scene document v7)
Scene documents gain a second kind of explicit move: `move.track`, timed
samples of position, orientation and an optional 35 mm-equivalent focal
length. Playback, Load, Replay and seek share one sampler, which stretches
the recording to the shot's duration (so editing durationSec retimes a
take), takes the short arc for longitude/heading/roll and returns recorded
samples exactly. Tracks require the ellipsoid altitude reference and are
bounded to 10,000 samples.

Shots may also carry a `lens`: focal length and a thin-lens depth of field
whose distances are divided by a miniature scale, so a 1:1000 rig renders
the city like a tabletop model. A new camera-lens owner applies it to the
frustum and a depth-of-field post-process stage; shots without a lens
restore the default view. SceneDirector accepts the lens as an injected
dependency, captures a changed lens with a shot and gains addRecordedShot()
for takes. Versions 1-6 import unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015H1iLusTzWpVKitGic2AW8
2026-10-19 04:03:02 +00:00

154 lines
4.2 KiB
JavaScript

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 later formats', () => {
const project = normalizeProject(
parseSceneDocument(
JSON.stringify({
version: 3,
scenes: [
{ shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] },
],
}),
),
);
assert.equal(project.version, 7);
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/,
);
});