feat: add Nepal flood incident scene

This commit is contained in:
manjunath22466 committed 2026-09-15 19:48:11 +05:30
1 parent 76c20be277
commit 60768ccc0b
48 files changed
+15431 -161

No files matched your search

+1246 -76
View File
File diff suppressed because it is too large. Load diff
+701 -2
View File
@@ -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.
+323
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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',