mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add Nepal flood incident scene
This commit is contained in:
1 parent
76c20be277
commit
60768ccc0b
48 files changed
+15431
-161
No files matched your search
+1246
-76
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,550 @@ import test from 'node:test';
|
||||
|
||||
import { SceneDirector } from './director.js';
|
||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
||||
|
||||
const NEPAL_ORIGINAL_SHOT_TITLES = [
|
||||
'Global Incident Context',
|
||||
'Nepal-Focused Globe Rotation',
|
||||
'Bhote Koshi Regional Approach',
|
||||
'Bhote Koshi Nearby Cities',
|
||||
'Bhote Koshi Incident Corridor',
|
||||
'Bhote Koshi Flood Path',
|
||||
'Bhote Koshi Corridor Overview',
|
||||
'Bhote Koshi Upper Valley',
|
||||
];
|
||||
|
||||
function nepalProjectFixture() {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].title = 'Nepal Flood Incident';
|
||||
project.scenes[0].shots = NEPAL_ORIGINAL_SHOT_TITLES.map((title, index) => ({
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
|
||||
id: `nepal-original-${index + 1}`,
|
||||
title,
|
||||
camera: {
|
||||
lat: 27 + index / 10,
|
||||
lon: 85 + index / 10,
|
||||
alt: 1000 + index * 100,
|
||||
heading: index * 10,
|
||||
pitch: -35 - index,
|
||||
roll: 0,
|
||||
},
|
||||
visual: {
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].visual),
|
||||
mapStack: 'photoreal',
|
||||
},
|
||||
layers: {
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2].layers),
|
||||
'bhote-koshi-locator': {
|
||||
enabled: index > 0,
|
||||
params: { presentation: `fixture-${index + 1}` },
|
||||
},
|
||||
},
|
||||
}));
|
||||
project.scenes[0].shots[7].layers['bhote-koshi-locator'] = {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-trigger-record' },
|
||||
};
|
||||
project.scenes[0].shots[7].holdSec = 8;
|
||||
return project;
|
||||
}
|
||||
|
||||
function legacyThreeShotNepalProjectFixture() {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].title = 'Nepal Flood Incident';
|
||||
project.scenes[0].shots = [0, 1, 2].map((index) => ({
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[index % 2]),
|
||||
id: `legacy-nepal-${index + 1}`,
|
||||
title: `Shot ${index + 1}`,
|
||||
camera: {
|
||||
lat: 20 + index,
|
||||
lon: 80 + index,
|
||||
alt: 1000000 + index * 100000,
|
||||
heading: index * 15,
|
||||
pitch: -50 - index,
|
||||
roll: 0,
|
||||
},
|
||||
}));
|
||||
return project;
|
||||
}
|
||||
|
||||
function legacyDefaultProjectWithoutNepalFixture() {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].id = 'bhote-koshi-flood';
|
||||
project.scenes[0].title = 'Bhote Koshi Flood Reconstruction';
|
||||
project.scenes[0].shots = [{
|
||||
...structuredClone(PROJECT_FIXTURE.scenes[0].shots[0]),
|
||||
id: 'standalone-shot',
|
||||
title: 'Shot 1',
|
||||
}];
|
||||
return project;
|
||||
}
|
||||
|
||||
|
||||
test('Mailung clip trim estimates seven seconds and its exit, including older saved holds', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
shot.sourcePackId = 'bhote-koshi-nepal-evidence-pack';
|
||||
shot.durationSec = 4.2;
|
||||
shot.holdSec = 15;
|
||||
shot.layers = { 'bhote-koshi-2026': { enabled: true, params: {
|
||||
presentation: 'scene-beat', beatId: 'mailung-bazzar',
|
||||
} } };
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 7.65);
|
||||
assert.equal(director._shotRuntimeDurationSec(scene, shot), 4.2 + 7.65);
|
||||
const state = director._layerStatesForShot(scene, shot)['bhote-koshi-2026'];
|
||||
assert.equal(state.params.sceneContext.holdSec, 7.65);
|
||||
assert.equal(state.params.sceneControls.mediaPlaybackHoldSec, 7);
|
||||
assert.equal(state.params.sceneControls.minimumHoldSec, 7.65);
|
||||
assert.equal(shot.holdSec, 15, 'runtime correction does not rewrite saved shots');
|
||||
shot.layers['bhote-koshi-2026'].params.beatId = 'dandagaun';
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 15, 'other shots keep their authored hold');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('Incident Corridor gives all overview pins time to reveal without rewriting saved shots', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
shot.holdSec = 0.9;
|
||||
shot.layers = { 'bhote-koshi-locator': {
|
||||
enabled: true, params: { presentation: 'bhote-koshi-incident-places' },
|
||||
} };
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 11);
|
||||
assert.equal(shot.holdSec, 0.9);
|
||||
shot.holdSec = 15;
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 15);
|
||||
shot.holdSec = 0.9;
|
||||
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-flood-path';
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
|
||||
shot.layers['bhote-koshi-locator'].params.presentation = 'bhote-koshi-incident-places';
|
||||
shot.layers['bhote-koshi-locator'].enabled = false;
|
||||
assert.equal(director._effectiveShotHoldSec(scene, shot), 0.9);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('scene clock seek resolves the exact shot phase and camera in both directions', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
const loads = [];
|
||||
director._loadShot = async (sceneId, shotId, options) => {
|
||||
loads.push({ sceneId, shotId, options });
|
||||
return { started: true, shotId };
|
||||
};
|
||||
try {
|
||||
assert.equal(await director.seekScene('scene-1', 0), true);
|
||||
assert.equal(await director.seekScene('scene-1', 0.99), true);
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||
assert.equal(await director.seekScene('missing', 0.5), false);
|
||||
assert.deepEqual(loads.map(({ sceneId, shotId }) => ({ sceneId, shotId })), [
|
||||
{ sceneId: 'scene-1', shotId: 'shot-a' },
|
||||
{ sceneId: 'scene-1', shotId: 'shot-b' },
|
||||
{ sceneId: 'scene-1', shotId: 'shot-a' },
|
||||
]);
|
||||
assert.equal(loads[0].options.sceneSeek.sceneProgress, 0);
|
||||
assert.equal(loads[0].options.sceneSeek.cameraProgress, 0);
|
||||
assert.ok(loads[1].options.sceneSeek.cameraProgress > 0.9);
|
||||
assert.equal(loads[1].options.sceneSeek.shotIndex, 1);
|
||||
assert.equal(loads[2].options.sceneSeek.sceneProgress, 0.25);
|
||||
assert.equal(loads[2].options.sceneSeek.cameraProgress, 0.5);
|
||||
assert.equal(loads[2].options.sceneSeek.camera.lat, 10);
|
||||
assert.equal(loads[2].options.sceneSeek.camera.lon, 20);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
const snapshots = [];
|
||||
const unsubscribe = director.subscribeSceneClock((snapshot) => snapshots.push(snapshot));
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
director._publishSceneClock(scene, scene.shots[0], 0.1, { running: true });
|
||||
director._publishSceneClock(scene, scene.shots[1], 0.3, { running: true });
|
||||
unsubscribe();
|
||||
director._publishSceneClock(scene, scene.shots[1], 0.4, { running: false });
|
||||
assert.deepEqual(snapshots.map(({ shotId, sceneElapsedSec, running }) => ({
|
||||
shotId, sceneElapsedSec, running,
|
||||
})), [
|
||||
{ shotId: 'shot-a', sceneElapsedSec: 0.1, running: true },
|
||||
{ shotId: 'shot-b', sceneElapsedSec: 0.3, running: true },
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('the Nepal evidence pack appends once and applies the approved corridor framing', () => {
|
||||
const project = nepalProjectFixture();
|
||||
const originalShots = structuredClone(project.scenes[0].shots);
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
const originalShotIds = scene.shots.map(({ id }) => id);
|
||||
const first = director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.deepEqual(first, {
|
||||
appended: true,
|
||||
updated: false,
|
||||
shotCount: 17,
|
||||
patchedShotCount: 14,
|
||||
firstShotId: scene.shots[8].id,
|
||||
});
|
||||
assert.deepEqual(scene.shots.slice(0, 8).map(({ id }) => id), originalShotIds);
|
||||
assert.equal(scene.shots.length, 25);
|
||||
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-2026'));
|
||||
assert.ok(scene.releaseLayerIds.includes('bhote-koshi-locator'));
|
||||
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.deepEqual(
|
||||
Object.keys(scene.appliedShotPacks[0].shotBindings),
|
||||
[...NEPAL_ORIGINAL_SHOT_TITLES, ...scene.shots.slice(8).map(({ title }) => title)],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots.map((shot) => shot.visual.mapStack),
|
||||
[
|
||||
...Array(25).fill('photoreal'),
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots.map((shot) => shot.layers['bhote-koshi-2026'].params.beatId),
|
||||
[
|
||||
'immediate-collapse-viewpoint',
|
||||
'debris-dammed-lake',
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'bidur-trishuli-bridge',
|
||||
'gyirong-border-gate',
|
||||
'immediate-collapse-viewpoint',
|
||||
'immediate-collapse-viewpoint', 'debris-dammed-lake', 'second-landslide',
|
||||
'gyirong-border-gate', 'timure-cluster', 'syabru-besi', 'dhunche',
|
||||
'mailung-upper-trishuli', 'mailung-bazzar', 'dandagaun', 'dandagaun-viewpoint',
|
||||
'betrawati-bazaar', 'bhainse', 'bidur-trishuli-bridge',
|
||||
'devighat-taadi-khola-bridge', 'charaudi', 'final-view',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[8].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{ evidenceMediaAutoplay: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[9].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{ evidenceMediaAutoplay: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[11].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 2.5,
|
||||
deferEvidenceUntilCameraSettled: true,
|
||||
evidenceRevealDurationSec: 2.5,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots.slice(12, 14).map(
|
||||
(shot) => shot.layers['bhote-koshi-2026'].params.sceneControls,
|
||||
),
|
||||
[
|
||||
{
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
{
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scene.shots[21].layers['bhote-koshi-2026'].params.sceneControls,
|
||||
{
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
);
|
||||
for (const shot of scene.shots) {
|
||||
assert.equal(shot.layers['bhote-koshi-2026'].enabled, true);
|
||||
assert.equal(shot.layers['bhote-koshi-2026'].params.presentation, 'scene-beat');
|
||||
}
|
||||
assert.deepEqual(
|
||||
scene.shots.slice(0, 8).filter((_, index) => index !== 4).map(({ camera }) => camera),
|
||||
originalShots.filter((_, index) => index !== 4).map(({ camera }) => camera),
|
||||
);
|
||||
assert.deepEqual(scene.shots[4].camera, {
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
alt: 126931,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
});
|
||||
assert.equal(scene.shots[4].holdSec, 11);
|
||||
assert.deepEqual(
|
||||
scene.shots.slice(0, 8).map((shot) => shot.layers['bhote-koshi-locator']),
|
||||
originalShots.map((shot) => shot.layers['bhote-koshi-locator']),
|
||||
);
|
||||
assert.equal(new Set(scene.shots.map(({ id }) => id)).size, 25);
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{ appended: false, reason: 'already-appended' },
|
||||
);
|
||||
assert.equal(scene.shots.length, 25);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('installed v12 Nepal pack inserts ten points without replacing renamed cameras', () => {
|
||||
const first = makeDirector({ project: nepalProjectFixture() });
|
||||
let legacy;
|
||||
const recipe = getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack');
|
||||
try {
|
||||
first.director.appendShotPack('scene-1', recipe.id);
|
||||
legacy = structuredClone(first.director._project);
|
||||
const scene = legacy.scenes[0];
|
||||
scene.shots = scene.shots.filter((shot) => !shot.sourcePackId
|
||||
|| recipe.previousRequiredSourcePackBeatIds.includes(shot.layers['bhote-koshi-2026'].params.beatId));
|
||||
scene.appliedShotPacks[0].version = 12;
|
||||
const ids = new Set(scene.shots.map(({ id }) => id));
|
||||
scene.appliedShotPacks[0].shotBindings = Object.fromEntries(
|
||||
Object.entries(scene.appliedShotPacks[0].shotBindings).filter(([, id]) => ids.has(id)));
|
||||
scene.shots[8].title = 'My collapse camera';
|
||||
scene.shots[8].camera.heading = 149;
|
||||
} finally { first.restore(); }
|
||||
const originalShots = structuredClone(legacy.scenes[0].shots);
|
||||
const upgraded = makeDirector({ project: legacy });
|
||||
try {
|
||||
const scene = upgraded.director._project.scenes[0];
|
||||
assert.equal(scene.shots.length, 25);
|
||||
for (const old of originalShots) {
|
||||
const current = scene.shots.find(({ id }) => id === old.id);
|
||||
assert.ok(current);
|
||||
assert.deepEqual(current.camera, old.camera);
|
||||
assert.equal(current.title, old.title);
|
||||
}
|
||||
assert.deepEqual(scene.shots.filter((shot) => shot.sourcePackId).map((shot) =>
|
||||
shot.layers['bhote-koshi-2026'].params.beatId), recipe.requiredSourcePackBeatIds);
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.equal(upgraded.director.appendShotPack(scene.id, recipe.id).reason, 'already-appended');
|
||||
} finally { upgraded.restore(); }
|
||||
});
|
||||
|
||||
|
||||
test('a legacy three-shot Nepal browser project bootstraps to the current 25-shot sequence', () => {
|
||||
const project = legacyThreeShotNepalProjectFixture();
|
||||
const originalIds = project.scenes[0].shots.map(({ id }) => id);
|
||||
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
assert.equal(scene.shots.length, 25);
|
||||
assert.deepEqual(scene.shots.map(({ title }) => title),
|
||||
getSceneAppendRecipeById('bhote-koshi-nepal-evidence-pack').requiredShotTitles);
|
||||
assert.deepEqual(scene.shots.slice(0, 3).map(({ id }) => id), originalIds);
|
||||
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
|
||||
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.equal(director._selectedSceneId, scene.id);
|
||||
assert.equal(director._selectedShotId, scene.shots[0].id);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Nepal evidence pack refuses a partial inventory without mutating the scene', () => {
|
||||
const project = nepalProjectFixture();
|
||||
project.scenes[0].shots[3].title = 'Renamed Nearby Cities';
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const sceneBefore = structuredClone(director._project.scenes[0]);
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{
|
||||
appended: false,
|
||||
updated: false,
|
||||
reason: 'shot-inventory-mismatch',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(director._project.scenes[0], sceneBefore);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Nepal pack upgrades the upper-valley shots without duplicating evidence beats', () => {
|
||||
const initial = makeDirector({ project: nepalProjectFixture() });
|
||||
initial.director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
const project = structuredClone(initial.director._project);
|
||||
initial.restore();
|
||||
project.scenes[0].appliedShotPacks = [{
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 4,
|
||||
}];
|
||||
for (const shot of project.scenes[0].shots.slice(0, 8)) {
|
||||
delete shot.layers['bhote-koshi-2026'];
|
||||
}
|
||||
const originalLength = project.scenes[0].shots.length;
|
||||
const originalIds = project.scenes[0].shots.map(({ id }) => id);
|
||||
const originalCameras = project.scenes[0].shots.map(({ camera }) => structuredClone(camera));
|
||||
const originalLocators = project.scenes[0].shots.map(
|
||||
(shot) => structuredClone(shot.layers['bhote-koshi-locator']),
|
||||
);
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{ appended: false, reason: 'already-appended' },
|
||||
);
|
||||
assert.equal(scene.shots.length, originalLength);
|
||||
assert.deepEqual(scene.shots.map(({ id }) => id), originalIds);
|
||||
assert.deepEqual(scene.shots.map(({ camera }) => camera), originalCameras);
|
||||
assert.deepEqual(
|
||||
scene.shots.map((shot) => shot.layers['bhote-koshi-locator']),
|
||||
originalLocators,
|
||||
);
|
||||
assert.deepEqual(scene.shots[8].camera, {
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
alt: 9850,
|
||||
heading: 146,
|
||||
pitch: -34,
|
||||
roll: 0,
|
||||
});
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
assert.equal(Object.keys(scene.appliedShotPacks[0].shotBindings).length, 25);
|
||||
assert.ok(scene.shots.every((shot) => (
|
||||
shot.layers['bhote-koshi-2026']?.enabled === true
|
||||
&& shot.layers['bhote-koshi-2026']?.params?.presentation === 'scene-beat'
|
||||
)));
|
||||
const afterUpgrade = structuredClone(scene);
|
||||
assert.deepEqual(
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack'),
|
||||
{ appended: false, reason: 'already-appended' },
|
||||
);
|
||||
assert.deepEqual(scene, afterUpgrade);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('an older default project gains the complete selectable Nepal scene once', () => {
|
||||
const project = legacyDefaultProjectWithoutNepalFixture();
|
||||
const { director, restore } = makeDirector({
|
||||
project,
|
||||
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
||||
});
|
||||
try {
|
||||
const nepal = director._project.scenes.find(({ title }) => title === 'Nepal Flood Incident');
|
||||
assert.ok(nepal);
|
||||
assert.equal(nepal.shots.length, 25);
|
||||
assert.equal(nepal.shots[0].title, 'Global Incident Context');
|
||||
assert.equal(nepal.shots.at(-1).title, 'Final view');
|
||||
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a previously installed Nepal scene stays deleted when its marker remains', () => {
|
||||
const project = legacyDefaultProjectWithoutNepalFixture();
|
||||
project.installedBuiltInSceneIds = ['bhote-koshi-nepal-scene'];
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
assert.equal(
|
||||
director._project.scenes.some(({ title }) => title === 'Nepal Flood Incident'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('public defaults include Nepal without an extra standalone flood recipe', () => {
|
||||
assert.equal(SCENE_RECIPES.some((item) => item.id === 'bhote-koshi-flood'), false);
|
||||
assert.equal(SCENE_RECIPES.filter((item) => item.id === 'bhote-koshi-nepal-scene').length, 1);
|
||||
});
|
||||
|
||||
|
||||
test('an existing public default project gains Nepal without replacing authored shots', () => {
|
||||
const project = structuredClone(PROJECT_FIXTURE);
|
||||
project.scenes[0].id = 'flights-radar';
|
||||
const original = structuredClone(project.scenes[0].shots);
|
||||
const { director, restore } = makeDirector({ project });
|
||||
try {
|
||||
assert.deepEqual(director._project.scenes[0].shots.map(({ id, camera }) => ({ id, camera })),
|
||||
original.map(({ id, camera }) => ({ id, camera })));
|
||||
assert.equal(director._project.scenes[1].title, 'Nepal Flood Incident');
|
||||
assert.equal(director._project.scenes[1].shots.length, 25);
|
||||
assert.deepEqual(director._project.installedBuiltInSceneIds, ['bhote-koshi-nepal-scene']);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('Nepal comparison shots load Esri beneath Vantor even from a saved OSM or photoreal shot', async () => {
|
||||
const { director, styleManager, restore } = makeDirector({
|
||||
project: nepalProjectFixture(),
|
||||
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
||||
});
|
||||
try {
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
const scene = director._project.scenes[0];
|
||||
const comparisonShots = scene.shots.filter((shot) =>
|
||||
director._layerStatesForShot(scene, shot)['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison);
|
||||
assert.ok(comparisonShots.length >= 2, 'the real comparison beats must be exercised');
|
||||
for (const shot of comparisonShots) {
|
||||
for (const prior of ['osm', 'photoreal']) {
|
||||
shot.visual.mapStack = prior;
|
||||
await director.loadShot(scene.id, shot.id, { flyDuration: 0.2 });
|
||||
assert.equal(styleManager.visualStates.at(-1).mapStack, 'esri-imagery', shot.title);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('all saved Nepal shots choose a usable map in keyed and keyless runtimes without rewriting the project', async () => {
|
||||
let photorealAvailable = false;
|
||||
const { director, styleManager, restore } = makeDirector({
|
||||
project: nepalProjectFixture(),
|
||||
isMapStackAvailable: (id) => id === 'photoreal' && photorealAvailable,
|
||||
data: { registered: [...REGISTERED, 'bhote-koshi-2026', 'bhote-koshi-locator'] },
|
||||
});
|
||||
try {
|
||||
director.appendShotPack('scene-1', 'bhote-koshi-nepal-evidence-pack');
|
||||
const scene = director._project.scenes[0];
|
||||
const savedShots = structuredClone(scene.shots);
|
||||
for (const available of [false, true, false]) {
|
||||
photorealAvailable = available;
|
||||
for (const shot of scene.shots) {
|
||||
const comparison = director._layerStatesForShot(scene, shot)
|
||||
['bhote-koshi-2026']?.params?.sceneControls?.imageryComparison;
|
||||
const visual = director._visualStateForShot(shot);
|
||||
assert.equal(visual.mapStack, available && !comparison ? 'photoreal' : 'esri-imagery', shot.title);
|
||||
}
|
||||
await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2 });
|
||||
assert.equal(styleManager.visualStates.at(-1).mapStack, available ? 'photoreal' : 'esri-imagery');
|
||||
}
|
||||
assert.deepEqual(scene.shots, savedShots);
|
||||
const unrelated = { visual: { mapStack: 'photoreal' }, layers: { flights: { enabled: true } } };
|
||||
assert.equal(director._visualStateForShot(unrelated), unrelated.visual, 'other scenes keep their provider policy');
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
|
||||
const REGISTERED = [
|
||||
@@ -98,6 +641,10 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
|
||||
const committed = [];
|
||||
return {
|
||||
setEnabledCalls,
|
||||
subscribeVisibilityRequests(listener) {
|
||||
this.visibilityListener = listener;
|
||||
return () => { this.visibilityListener = null; };
|
||||
},
|
||||
setParamsCalls,
|
||||
committed,
|
||||
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
||||
@@ -174,13 +721,67 @@ function makeDirector(options = {}) {
|
||||
const viewer = fakeViewer();
|
||||
const styleManager = fakeStyleManager(options.style);
|
||||
const dataManager = fakeDataManager(options.data);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
isMapStackAvailable: options.isMapStackAvailable,
|
||||
});
|
||||
// Telemetry is only accumulated during a run; observable-failure assertions
|
||||
// need the accumulator without driving a whole run.
|
||||
director._activeRun = { events: [] };
|
||||
return { director, viewer, styleManager, dataManager, restore };
|
||||
}
|
||||
|
||||
test('scene camera waits for provider completion and fade rather than the saved media hold', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
let release;
|
||||
let pending = true;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
const scene = director._project.scenes[0];
|
||||
scene.shots[0].layers.flights.params = { beatId: 'trimmed' };
|
||||
dataManager.layers = new Map([['flights', { module: {
|
||||
getSceneShotMediaHold: (id) => id === 'trimmed' ? { pending, maxWaitMs: 20000 } : null,
|
||||
} }]]);
|
||||
const waits = [];
|
||||
director._sleep = async (ms) => { waits.push(ms); if (ms === 70) await gate; };
|
||||
const run = director.startScene('scene-1', { single: true, preview: false });
|
||||
await settle(40);
|
||||
assert.equal(viewer.flights.length, 1, 'no next flight while source is starting, playing or fading');
|
||||
assert.deepEqual(waits, [70]);
|
||||
pending = false; release();
|
||||
await run;
|
||||
assert.equal(viewer.flights.length, 2, 'the next shot starts immediately after owned media completion');
|
||||
} finally { release(); await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('Stop cancels a provider-owned shot hold and late completion cannot fly the next camera', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
dataManager.layers = new Map([['flights', { module: {
|
||||
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 20000 }),
|
||||
} }]]);
|
||||
director._sleep = () => gate;
|
||||
const run = director.startScene('scene-1', { single: true, preview: false });
|
||||
await settle(40);
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
director.stopScene('test stop'); release(); await run;
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
assert.equal(director.running, false);
|
||||
} finally { release(); await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('a provider-owned hold fails boundedly if its owner never settles', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
director.dataManager.layers = new Map([['flights', { module: {
|
||||
getSceneShotMediaHold: () => ({ pending: true, maxWaitMs: 0 }),
|
||||
} }]]);
|
||||
const scene = director._project.scenes[0];
|
||||
await assert.rejects(director._holdShot(scene, scene.shots[0], { cancelled: false }), /bounded playback window/);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
/** The layer map a shipped recipe declares, in normalized form. */
|
||||
function recipeLayers(recipeId) {
|
||||
const recipe = SCENE_RECIPES.find((item) => item.id === recipeId);
|
||||
@@ -189,6 +790,104 @@ function recipeLayers(recipeId) {
|
||||
);
|
||||
}
|
||||
|
||||
test('destroyed directors refuse seek, replay, adjacent and continuation without writes', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
try {
|
||||
await director.destroy();
|
||||
director._loadedSceneId = 'scene-1';
|
||||
assert.equal(await director.seekScene('scene-1', 0), false);
|
||||
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
|
||||
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
|
||||
assert.equal((await director.continueScene('scene-1', 'shot-a')).started, false);
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
assert.equal(dataManager.setParamsCalls.length, 0);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('replay, adjacent and seek report layer refusal rather than success', async () => {
|
||||
const { director, restore } = makeDirector({ data: { refuse: () => true } });
|
||||
try {
|
||||
assert.equal((await director.replayShot('scene-1', 'shot-a')).started, false);
|
||||
assert.equal(await director.loadAdjacentShot('scene-1', 'shot-a', 1), false);
|
||||
assert.equal(await director.seekScene('scene-1', 0), false);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('a seek waiting for run teardown is revoked by newer Stop, Load, Start or destroy', async () => {
|
||||
for (const action of ['stop', 'load', 'start', 'destroy']) {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
let release;
|
||||
director._waitForRunIdle = () => new Promise((resolve) => { release = resolve; });
|
||||
director._running = true;
|
||||
director._runToken = { cancelled: false };
|
||||
const seek = director.seekScene('scene-1', 0);
|
||||
assert.equal(director._pendingWork.has(seek), true);
|
||||
let newer;
|
||||
if (action === 'stop') director.stopScene();
|
||||
if (action === 'load') newer = director.loadShot('scene-1', 'shot-b');
|
||||
if (action === 'start') newer = director.startScene('scene-1');
|
||||
if (action === 'destroy') newer = director.destroy();
|
||||
director._running = false;
|
||||
release();
|
||||
assert.equal(await seek, false, action);
|
||||
await newer;
|
||||
} finally { await director.destroy(); restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit scene-layer OFF revokes continuation during enable, flight and hold; internal OFF does not', async () => {
|
||||
for (const phase of ['enable', 'flight', 'hold']) {
|
||||
const { director, dataManager, restore } = makeDirector();
|
||||
try {
|
||||
director._project.scenes[0].releaseLayerIds = ['flights'];
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
if (phase === 'enable') {
|
||||
const enable = dataManager.setEnabled.bind(dataManager);
|
||||
dataManager.setEnabled = async (...args) => { await gate; return enable(...args); };
|
||||
} else if (phase === 'flight') director._flyCamera = () => gate;
|
||||
else director._sleep = () => gate;
|
||||
director._project.scenes[0].shots[0].holdSec = 1;
|
||||
const run = director.startScene('scene-1', { single: true, preview: false });
|
||||
await settle(40);
|
||||
const token = director._runToken;
|
||||
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'scene' });
|
||||
assert.equal(token.cancelled, false);
|
||||
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'user' });
|
||||
assert.equal(token.cancelled, true, phase);
|
||||
dataManager.visibilityListener({ layerId: 'flights', enabled: false, origin: 'tool' });
|
||||
release();
|
||||
await run;
|
||||
assert.equal(dataManager.committed.some(({ id, enabled }) => id === 'traffic' && enabled), false);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('scene preview owns recording chrome while panel playback leaves it available', async () => {
|
||||
for (const preview of [true, false]) {
|
||||
const { director, styleManager, restore } = makeDirector();
|
||||
const recording = [];
|
||||
const playback = [];
|
||||
let release;
|
||||
const flight = new Promise((resolve) => { release = resolve; });
|
||||
director._flyCamera = () => flight;
|
||||
styleManager.setRecordingMode = (active) => recording.push(active);
|
||||
director._setPlaybackActive = (active) => playback.push(active);
|
||||
try {
|
||||
const run = director.startScene('scene-1', { single: true, preview });
|
||||
await settle(40);
|
||||
assert.deepEqual(recording, preview ? [true] : []);
|
||||
assert.deepEqual(playback, preview ? [true] : []);
|
||||
director.stopScene();
|
||||
release();
|
||||
await run;
|
||||
assert.deepEqual(recording, preview ? [true, false] : []);
|
||||
assert.deepEqual(playback, preview ? [true, false] : []);
|
||||
} finally { release(); await director.destroy(); restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('the director reconciles only the layers a shot declares', async () => {
|
||||
// Regression: _applyLayerStates walked the LIVE registry and forced every
|
||||
// undeclared layer off, tearing down CCTV/vessels/fires with no restore pass.
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
|
||||
|
||||
/**
|
||||
* Nepal evidence expansion. Coordinates and camera framing come from
|
||||
* the source event pack; existing authored cameras remain owned by the Scene.
|
||||
*/
|
||||
const ADDITIONAL_SHOTS = [
|
||||
{
|
||||
id: 'second-landslide',
|
||||
title: 'Second landslide',
|
||||
lat: 28.399234,
|
||||
lon: 85.535806,
|
||||
alt: 10145,
|
||||
heading: 224,
|
||||
pitch: -38,
|
||||
hold: 11
|
||||
},
|
||||
{
|
||||
id: 'dhunche',
|
||||
title: 'Dhunche',
|
||||
lat: 28.161431,
|
||||
lon: 85.324278,
|
||||
alt: 6212,
|
||||
heading: 208,
|
||||
pitch: -39,
|
||||
hold: 16
|
||||
},
|
||||
{
|
||||
id: 'mailung-upper-trishuli',
|
||||
title: 'Mailung / Upper Trishuli bridge',
|
||||
lat: 28.140642,
|
||||
lon: 85.265996,
|
||||
alt: 6353,
|
||||
heading: 211,
|
||||
pitch: -40,
|
||||
hold: 13
|
||||
},
|
||||
{
|
||||
id: 'mailung-bazzar',
|
||||
title: 'Mailung Bazzar, Dandaguan',
|
||||
lat: 28.126526,
|
||||
lon: 85.248027,
|
||||
alt: 6331,
|
||||
heading: 212,
|
||||
pitch: -40,
|
||||
hold: 15
|
||||
},
|
||||
{
|
||||
id: 'dandagaun',
|
||||
title: 'Dandagaun, Rasuwa',
|
||||
lat: 28.124424,
|
||||
lon: 85.245154,
|
||||
alt: 6311,
|
||||
heading: 214,
|
||||
pitch: -40,
|
||||
hold: 15
|
||||
},
|
||||
{
|
||||
id: 'dandagaun-viewpoint',
|
||||
title: 'Dandagaun viewpoint restaurant',
|
||||
lat: 28.086663,
|
||||
lon: 85.229914,
|
||||
alt: 6740,
|
||||
heading: 215,
|
||||
pitch: -41,
|
||||
hold: 13
|
||||
},
|
||||
{
|
||||
id: 'betrawati-bazaar',
|
||||
title: 'Betrawati Bazaar',
|
||||
lat: 28.029827,
|
||||
lon: 85.227694,
|
||||
alt: 7034,
|
||||
heading: 216,
|
||||
pitch: -41,
|
||||
hold: 12
|
||||
},
|
||||
{
|
||||
id: 'bhainse',
|
||||
title: 'Bhainse',
|
||||
lat: 28.023087,
|
||||
lon: 85.229449,
|
||||
alt: 8051,
|
||||
heading: 219,
|
||||
pitch: -41,
|
||||
hold: 13
|
||||
},
|
||||
{
|
||||
id: 'devighat-taadi-khola-bridge',
|
||||
title: 'Devighat / Taadi Khola Bridge',
|
||||
lat: 27.921132,
|
||||
lon: 85.177113,
|
||||
alt: 9056,
|
||||
heading: 224,
|
||||
pitch: -42,
|
||||
hold: 13
|
||||
},
|
||||
{
|
||||
id: 'charaudi',
|
||||
title: 'Charaudi',
|
||||
lat: 27.865095,
|
||||
lon: 84.838424,
|
||||
alt: 10347,
|
||||
heading: 232,
|
||||
pitch: -42,
|
||||
hold: 11
|
||||
},
|
||||
{
|
||||
id: 'final-view',
|
||||
title: 'Final view',
|
||||
lat: 28.0834,
|
||||
lon: 85.206,
|
||||
alt: 113520,
|
||||
heading: 64,
|
||||
pitch: -83,
|
||||
roll: 174,
|
||||
duration: 4,
|
||||
hold: 11,
|
||||
locatorPresentation: 'bhote-koshi-incident-places'
|
||||
}
|
||||
];
|
||||
const EVIDENCE_ORDER = [
|
||||
'immediate-collapse-viewpoint',
|
||||
'debris-dammed-lake',
|
||||
'second-landslide',
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'dhunche',
|
||||
'mailung-upper-trishuli',
|
||||
'mailung-bazzar',
|
||||
'dandagaun',
|
||||
'dandagaun-viewpoint',
|
||||
'betrawati-bazaar',
|
||||
'bhainse',
|
||||
'bidur-trishuli-bridge',
|
||||
'devighat-taadi-khola-bridge',
|
||||
'charaudi',
|
||||
'final-view'
|
||||
];
|
||||
|
||||
// Per-reach motion timing keeps the schematic front ahead of the fixed 4.2s
|
||||
// camera flights without pretending short urban reaches and long valley runs
|
||||
// should advance at one global speed.
|
||||
const PATH_DURATION_BY_BEAT = Object.freeze({
|
||||
'debris-dammed-lake': 4.8,
|
||||
'second-landslide': 6.5,
|
||||
'timure-cluster': 2.1,
|
||||
'syabru-besi': 2.8,
|
||||
'dhunche': 2.5,
|
||||
'mailung-upper-trishuli': 2.9,
|
||||
'mailung-bazzar': 2.1,
|
||||
'dandagaun': 1.2,
|
||||
'dandagaun-viewpoint': 2.5,
|
||||
'betrawati-bazaar': 2.9,
|
||||
'bhainse': 1.5,
|
||||
'bidur-trishuli-bridge': 2.6,
|
||||
});
|
||||
|
||||
/** Extend the six-beat Nepal pack without replacing existing authored shots. */
|
||||
export function expandNepalEvidencePack(base) {
|
||||
const layerId = base.requiredSourcePackLayerId;
|
||||
const extraShots = ADDITIONAL_SHOTS.map(({
|
||||
id,
|
||||
hold,
|
||||
duration = 4.2,
|
||||
locatorPresentation,
|
||||
...camera
|
||||
}) => ({
|
||||
...camera,
|
||||
roll: Number.isFinite(Number(camera.roll)) ? Number(camera.roll) : 0,
|
||||
duration,
|
||||
hold,
|
||||
mapStack: 'photoreal',
|
||||
layers: {
|
||||
[layerId]: { enabled: true, params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: id,
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
} },
|
||||
...(locatorPresentation ? {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: locatorPresentation },
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
}));
|
||||
const byBeat = new Map([...base.cameraPath, ...extraShots].map((shot) => [
|
||||
shot.layers[layerId].params.beatId, shot,
|
||||
]));
|
||||
const cameraPath = EVIDENCE_ORDER.map((id) => byBeat.get(id));
|
||||
const runtimeControlsByBeat = { ...base.runtimeControlsByBeat };
|
||||
const cameraLedPathBeats = new Set([
|
||||
'timure-cluster', 'syabru-besi', ...SOURCE_PATH_BEAT_IDS.slice(2),
|
||||
]);
|
||||
const mediaLedPathBeats = new Set([
|
||||
'debris-dammed-lake', 'second-landslide',
|
||||
]);
|
||||
const finalSourcedPathBeatId = SOURCE_PATH_BEAT_IDS.at(-1);
|
||||
const postSourceHistoryBeats = new Set([
|
||||
'devighat-taadi-khola-bridge', 'charaudi', 'final-view',
|
||||
]);
|
||||
for (const shot of cameraPath) {
|
||||
const id = shot.layers[layerId].params.beatId;
|
||||
const isFinalView = id === 'final-view';
|
||||
const authoredPathDurationSec = PATH_DURATION_BY_BEAT[id];
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidenceMediaAutoplay: !isFinalView,
|
||||
...(isFinalView ? {} : {
|
||||
mediaPlaybackHoldSec: id === 'mailung-bazzar' ? 7 : 6,
|
||||
mediaExitDurationSec: 0.65,
|
||||
}),
|
||||
deferEvidenceUntilCameraSettled: !isFinalView,
|
||||
evidenceRevealDurationSec: 0.9,
|
||||
evidencePath: SOURCE_PATH_BEAT_IDS.includes(id) ? 'source'
|
||||
: ['gyirong-border-gate', 'timure-cluster', 'syabru-besi'].includes(id) ? 'comparison' : 'none',
|
||||
...(cameraLedPathBeats.has(id) ? {
|
||||
evidencePathDuringCamera: true,
|
||||
evidencePathPersistent: true,
|
||||
...(authoredPathDurationSec
|
||||
? { evidencePathTravelDurationSec: authoredPathDurationSec }
|
||||
: {}),
|
||||
} : {}),
|
||||
...(mediaLedPathBeats.has(id) ? {
|
||||
evidencePathDuringMedia: true,
|
||||
evidencePathPersistent: true,
|
||||
evidencePathStartDelaySec: 0,
|
||||
} : {}),
|
||||
};
|
||||
if (SOURCE_PATH_BEAT_IDS.includes(id)) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidenceHoldCard: true,
|
||||
evidencePathHistory: true,
|
||||
evidencePathDurationSec: authoredPathDurationSec || Math.max(4, shot.hold),
|
||||
minimumHoldSec: Math.max(4, shot.hold),
|
||||
};
|
||||
}
|
||||
if (SOURCE_PATH_BEAT_IDS.slice(2).includes(id)) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidencePathHistory: true,
|
||||
evidencePathElevation: 'source',
|
||||
};
|
||||
}
|
||||
if (postSourceHistoryBeats.has(id)) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidencePath: 'history',
|
||||
evidencePathHistory: true,
|
||||
evidencePathHistoryBeatId: finalSourcedPathBeatId,
|
||||
evidencePathPersistent: true,
|
||||
evidencePathElevation: 'source',
|
||||
};
|
||||
}
|
||||
const pathDurationSec = Number(runtimeControlsByBeat[id].evidencePathDurationSec)
|
||||
|| (!cameraLedPathBeats.has(id) && runtimeControlsByBeat[id].evidenceSequence === true
|
||||
? Number(runtimeControlsByBeat[id].evidenceSequenceDurationSec) || 0
|
||||
: 0);
|
||||
const mediaPresentationSec = isFinalView
|
||||
? 0
|
||||
: (id === 'mailung-bazzar' ? 7 : 6) + 0.65;
|
||||
const mediaAndPathHoldSec = pathDurationSec > 0
|
||||
&& !cameraLedPathBeats.has(id)
|
||||
? mediaLedPathBeats.has(id)
|
||||
? Math.max(mediaPresentationSec, pathDurationSec)
|
||||
: mediaPresentationSec + pathDurationSec
|
||||
: mediaPresentationSec;
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
minimumHoldSec: id === 'mailung-bazzar' ? mediaAndPathHoldSec : Math.max(
|
||||
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
|
||||
mediaAndPathHoldSec,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Keep the downstream sequence on its sourced river profile. Coarse Google
|
||||
// mesh samples can contain cliff-height spikes that reverse apparent motion.
|
||||
// Preserve each authored readable hold after the media-first timing floor.
|
||||
for (const [id, seconds, sequenceSeconds = seconds] of [
|
||||
['immediate-collapse-viewpoint', 10], ['gyirong-border-gate', 8, 2.5],
|
||||
['timure-cluster', 12], ['syabru-besi', 11],
|
||||
]) {
|
||||
runtimeControlsByBeat[id] = {
|
||||
...runtimeControlsByBeat[id],
|
||||
evidenceHoldCard: true,
|
||||
evidenceSequenceDurationSec: sequenceSeconds,
|
||||
minimumHoldSec: Math.max(
|
||||
Number(runtimeControlsByBeat[id].minimumHoldSec) || 0,
|
||||
seconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
const finalView = byBeat.get('final-view');
|
||||
return Object.freeze({
|
||||
...base, version: 18, cameraPath, runtimeControlsByBeat,
|
||||
// Clip trims must also replace older saved holds, not just lower a floor.
|
||||
runtimeHoldSecByBeat: { 'mailung-bazzar': 7 + 0.65 },
|
||||
expansionFromVersion: 17,
|
||||
previousRequiredSourcePackBeatIds: EVIDENCE_ORDER.slice(0, -1),
|
||||
previousRequiredSourcePackBeatIdVariants: [
|
||||
base.requiredSourcePackBeatIds,
|
||||
EVIDENCE_ORDER.slice(0, -1),
|
||||
],
|
||||
requiredSourcePackBeatIds: EVIDENCE_ORDER,
|
||||
requiredShotTitles: [
|
||||
...base.requiredShotTitles.slice(0, -base.cameraPath.length),
|
||||
...cameraPath.map(({ title }) => title),
|
||||
],
|
||||
adoptExistingShotTitles: ['Final view'],
|
||||
shotPatches: [
|
||||
...(base.shotPatches || []),
|
||||
{
|
||||
title: 'Final view',
|
||||
layers: finalView.layers,
|
||||
},
|
||||
],
|
||||
photorealSurfaceBeatIds: EVIDENCE_ORDER,
|
||||
});
|
||||
}
|
||||
+603
-1
@@ -3,7 +3,592 @@
|
||||
* Each recipe is deterministic so repeated runs produce similar footage.
|
||||
*/
|
||||
|
||||
export const SCENE_RECIPES = [
|
||||
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
|
||||
|
||||
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 12,
|
||||
title: 'Bhote Koshi Evidence Sequence',
|
||||
style: 'normal',
|
||||
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
'bhote-koshi-locator': false,
|
||||
},
|
||||
releaseLayerIds: ['bhote-koshi-2026', 'bhote-koshi-locator'],
|
||||
post: {
|
||||
bloom: 0,
|
||||
sharpen: true,
|
||||
detectionMode: 'OFF',
|
||||
mapStack: 'photoreal',
|
||||
},
|
||||
legacySceneBootstrap: {
|
||||
targetSceneTitle: 'Nepal Flood Incident',
|
||||
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
|
||||
cameraPath: [
|
||||
{
|
||||
title: 'Global Incident Context',
|
||||
lat: 27.5962,
|
||||
lon: 78.5718,
|
||||
alt: 15628002,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'nepal-context' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
lat: 28.7248,
|
||||
lon: 81.3226,
|
||||
alt: 2068966,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'nepal-context' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
lat: 27.0354,
|
||||
lon: 84.6287,
|
||||
alt: 809543,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-regional' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
lat: 27.891,
|
||||
lon: 85.428,
|
||||
alt: 225734,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-city-context' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
alt: 126931,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 11,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-incident-places' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
lat: 28.1691,
|
||||
lon: 85.352,
|
||||
alt: 60328,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-flood-path' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
lat: 27.8986,
|
||||
lon: 84.8723,
|
||||
alt: 53247,
|
||||
heading: 60,
|
||||
pitch: -48,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0.9,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-path-overview' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
lat: 28.4187,
|
||||
lon: 85.4341,
|
||||
alt: 7977,
|
||||
heading: 155,
|
||||
pitch: -28,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 8,
|
||||
layers: {
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: { presentation: 'bhote-koshi-trigger-record' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredShotTitles: [
|
||||
'Global Incident Context',
|
||||
'Nepal-Focused Globe Rotation',
|
||||
'Bhote Koshi Regional Approach',
|
||||
'Bhote Koshi Nearby Cities',
|
||||
'Bhote Koshi Incident Corridor',
|
||||
'Bhote Koshi Flood Path',
|
||||
'Bhote Koshi Corridor Overview',
|
||||
'Bhote Koshi Upper Valley',
|
||||
'Upper Valley Collapse',
|
||||
'Debris-Dammed Lake',
|
||||
'Nepal-China Border Gate',
|
||||
'Timure Evidence Cluster',
|
||||
'Syabru Besi Passage',
|
||||
'Bidur / Trishuli Consequence',
|
||||
],
|
||||
requiredSourcePackBeatIds: [
|
||||
'immediate-collapse-viewpoint',
|
||||
'debris-dammed-lake',
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'bidur-trishuli-bridge',
|
||||
],
|
||||
requiredSourcePackLayerId: 'bhote-koshi-2026',
|
||||
photorealSurfaceBeatIds: [
|
||||
'gyirong-border-gate',
|
||||
'timure-cluster',
|
||||
'syabru-besi',
|
||||
'bidur-trishuli-bridge',
|
||||
],
|
||||
runtimeControlsByBeat: {
|
||||
'immediate-collapse-viewpoint': { evidenceMediaAutoplay: true },
|
||||
'debris-dammed-lake': { evidenceMediaAutoplay: true },
|
||||
'gyirong-border-gate': {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 2.5,
|
||||
deferEvidenceUntilCameraSettled: true,
|
||||
evidenceRevealDurationSec: 2.5,
|
||||
},
|
||||
'timure-cluster': {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
'syabru-besi': {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
'bidur-trishuli-bridge': {
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
shotPatches: [
|
||||
{
|
||||
title: 'Global Incident Context',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'debris-dammed-lake',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'timure-cluster',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
camera: {
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
alt: 126931,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
},
|
||||
holdSec: 11,
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'syabru-besi',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'bidur-trishuli-bridge',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
holdSec: 8,
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
},
|
||||
},
|
||||
'bhote-koshi-locator': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'bhote-koshi-trigger-record',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Upper Valley Collapse',
|
||||
camera: {
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
alt: 9850,
|
||||
heading: 146,
|
||||
pitch: -34,
|
||||
roll: 0,
|
||||
},
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-China Border Gate',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
title: 'Timure Evidence Cluster',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
title: 'Syabru Besi Passage',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
],
|
||||
cameraPath: [
|
||||
{
|
||||
title: 'Upper Valley Collapse',
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
alt: 9850,
|
||||
heading: 146,
|
||||
pitch: -34,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 1.4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Debris-Dammed Lake',
|
||||
lat: 28.392914,
|
||||
lon: 85.570921,
|
||||
alt: 10744,
|
||||
heading: 232,
|
||||
pitch: -37,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 1.4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'debris-dammed-lake',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nepal-China Border Gate',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.316789,
|
||||
lon: 85.399734,
|
||||
alt: 5900,
|
||||
heading: 208,
|
||||
pitch: -42,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 2.5,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 2.5,
|
||||
deferEvidenceUntilCameraSettled: true,
|
||||
evidenceRevealDurationSec: 2.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Timure Evidence Cluster',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.285968,
|
||||
lon: 85.379136,
|
||||
alt: 5032,
|
||||
heading: 198,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'timure-cluster',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Syabru Besi Passage',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.210306,
|
||||
lon: 85.361527,
|
||||
alt: 5893,
|
||||
heading: 205,
|
||||
pitch: -38,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'syabru-besi',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
imageryComparison: true,
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
mapStack: 'photoreal',
|
||||
lat: 27.981737,
|
||||
lon: 85.196273,
|
||||
alt: 7422,
|
||||
heading: 215,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
duration: 4.2,
|
||||
hold: 4,
|
||||
layers: {
|
||||
'bhote-koshi-2026': {
|
||||
enabled: true,
|
||||
params: {
|
||||
presentation: 'scene-beat',
|
||||
beatId: 'bidur-trishuli-bridge',
|
||||
beatReveal: 0.36,
|
||||
split: 0.5,
|
||||
sceneControls: {
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
|
||||
? expandNepalEvidencePack(BHOTE_KOSHI_NEPAL_BASE_RECIPE)
|
||||
: null;
|
||||
|
||||
// Fresh browser origins have no legacy three-shot Nepal project for the
|
||||
// append recipe to upgrade. Seed that exact legacy shape so SceneDirector can
|
||||
// run the same idempotent bootstrap used by existing browser-saved projects.
|
||||
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
? Object.freeze({
|
||||
id: 'bhote-koshi-nepal-scene',
|
||||
title: 'Nepal Flood Incident',
|
||||
durationSec: 15,
|
||||
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
|
||||
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
|
||||
layers: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.layers,
|
||||
releaseLayerIds: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.releaseLayerIds,
|
||||
post: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.post,
|
||||
installAlongsideSceneId: 'bhote-koshi-flood',
|
||||
installAlongsideFallbackSceneId: 'flights-radar',
|
||||
cameraPath: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.legacySceneBootstrap.cameraPath
|
||||
.slice(0, 3)
|
||||
.map((keyframe, index) => ({ ...keyframe, title: `Shot ${index + 1}` })),
|
||||
})
|
||||
: null;
|
||||
|
||||
const EVENT_RECIPES = [
|
||||
BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE,
|
||||
].filter(Boolean);
|
||||
|
||||
const PUBLIC_SCENE_RECIPES = [
|
||||
{
|
||||
id: 'flights-radar',
|
||||
title: 'Global Flights Radar',
|
||||
@@ -161,6 +746,23 @@ export const SCENE_RECIPES = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Build the recipe list without mutating stored user-authored projects. */
|
||||
export function createSceneRecipes({
|
||||
localDemoRecipes = EVENT_RECIPES,
|
||||
} = {}) {
|
||||
return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES];
|
||||
}
|
||||
|
||||
export const SCENE_RECIPES = createSceneRecipes();
|
||||
|
||||
export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE]
|
||||
: [];
|
||||
|
||||
export function getSceneRecipeById(id) {
|
||||
return SCENE_RECIPES.find((recipe) => recipe.id === id) || null;
|
||||
}
|
||||
|
||||
export function getSceneAppendRecipeById(id) {
|
||||
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ function sweepLayerParamKeys() {
|
||||
|
||||
/** The layer registry as main.js builds it (src/main.js dataManager.register calls). */
|
||||
const REGISTERED = new Set([
|
||||
'bhote-koshi-2026', 'bhote-koshi-locator',
|
||||
'flights', 'military', 'earthquakes', 'satellites', 'rocket-launches', 'traffic',
|
||||
'cctv', 'radio', 'bikeshare', 'ais-live-vessels', 'military-installations',
|
||||
'military-awareness', 'local-datacenters', 'local-dams',
|
||||
|
||||
Reference in new issue
Block a user