fix: scope Nepal media playback to explicit scene actions

This commit is contained in:
manjunath22466 committed 2026-09-16 12:14:15 +05:30
1 parent cf0b9927de
commit 621d186de1
10 files changed
+482 -5

No files matched your search

+2
View File
@@ -603,6 +603,7 @@ export function createBhoteKoshiEmbeddedMedia({
);
if (pinokioShell || !documentRef?.createElement || !viewer) {
return {
supportsPlayback: false,
warm: () => false,
show: () => false,
play: () => false,
@@ -1084,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
return {
supportsPlayback: true,
warm,
show,
getPlaybackState: () => record?.youtubeSession?.getState() || null,
+47 -1
View File
@@ -1000,6 +1000,8 @@ export function createBhoteKoshiEventLayer({
let _sceneMediaPlaybackBeatId = null;
let _sceneMediaPlaybackCompletedBeatId = null;
let _sceneMediaPlaybackTimer = null;
let _sceneMediaOwner = null;
let _removeSceneMediaAbort = null;
let _witnessEmbedActive = false;
let _sceneRevealAnimationFrame = null;
let _sceneRevealHoldActive = false;
@@ -1261,8 +1263,41 @@ export function createBhoteKoshiEventLayer({
return true;
}
// Playback authority is supplied by the Director, never restored from params.
function setSceneMediaPlayback(owner = null) {
_removeSceneMediaAbort?.();
_removeSceneMediaAbort = null;
_sceneMediaOwner = owner;
stopSceneMediaPlayback();
releaseEvidenceVideo({ restorePoster: false });
_embeddedMedia?.hide?.({ immediate: true });
_embeddedEvidenceIndex = -1;
_sceneMediaPlaybackCompletedBeatId = null;
if (owner?.token?.signal) {
const signal = owner.token.signal;
const cancel = () => setSceneMediaPlayback();
if (signal.aborted) cancel();
else {
signal.addEventListener('abort', cancel, { once: true });
_removeSceneMediaAbort = () =>
signal.removeEventListener('abort', cancel);
}
}
}
function sceneMediaPlaybackActive() {
return (
_sceneMediaOwner != null &&
!_sceneMediaOwner.token.cancelled &&
!_sceneMediaOwner.token.signal?.aborted &&
_sceneMediaOwner.sceneId === _sceneContext?.sceneId &&
_sceneMediaOwner.shotId === _sceneContext?.shotId
);
}
function sceneEvidenceMediaAutoplays() {
return (
sceneMediaPlaybackActive() &&
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
_sceneControls.evidenceMediaAutoplay === true &&
@@ -1333,7 +1368,7 @@ export function createBhoteKoshiEventLayer({
stopSceneMediaPlayback({ pauseMedia: false });
_sceneMediaPlaybackBeatId = beatId;
_embeddedMedia?.play?.();
if (sceneUsesTrimmedMedia(beatId)) {
if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) {
const deadline = Date.now() + 18000;
const check = () => {
if (
@@ -1415,6 +1450,14 @@ export function createBhoteKoshiEventLayer({
!sceneUsesTrimmedMedia(beatId)
)
return null;
const media = _evidenceTimeline.find(
(item) => item.observation?.id === beatId,
)?.observation?.media;
// Deliberate provider suppression is a source card, not a player waiting
// to load. Returning no gate selects the Director's authored card dwell.
// A local clip still owns playback; a starting supported provider still waits.
if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath)
return null;
return {
pending:
_sceneMediaPlaybackCompletedBeatId !== beatId ||
@@ -2103,6 +2146,7 @@ export function createBhoteKoshiEventLayer({
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
const warmingEvidence =
sceneMediaPlaybackActive() &&
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT;
// Keep the same preload across camera frames; stop/disable still cancels it.
@@ -3476,6 +3520,7 @@ export function createBhoteKoshiEventLayer({
}
async function disable() {
setSceneMediaPlayback();
_enabled = false;
++_comparisonSurfaceGeneration;
_mapStackController?.clearTerrainComparison?.();
@@ -3770,6 +3815,7 @@ export function createBhoteKoshiEventLayer({
setParams,
getParams,
getSceneShotMediaHold,
setSceneMediaPlayback,
getStats() {
return {
count: _enabled ? 1 : 0,
+96 -1
View File
@@ -30,6 +30,8 @@ import {
} from './bhoteKoshiEvent.js';
import { SCENE_RECIPES } from '../scenes/recipes.js';
import { LayerLifecycle } from './lifecycle.js';
import { createBhoteKoshiEmbeddedMedia } from './bhoteKoshiEmbeddedMedia.js';
import { SceneDirector } from '../scenes/director.js';
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
@@ -277,8 +279,9 @@ test('trimmed scene media holds through provider startup, playback and fade, the
try {
const viewer = eventViewer();
await layer.init(viewer);
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { holdSec: 7.65 },
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true);
@@ -301,6 +304,94 @@ test('trimmed scene media holds through provider startup, playback and fade, the
}
});
test('Pinokio source-card fallback selects authored Director dwell, not an impossible player hold', async () => {
const previous = { document: globalThis.document, window: globalThis.window };
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async url => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
embeddedMediaFactory: options => createBhoteKoshiEmbeddedMedia({ ...options,
globalRef: { navigator: { userAgent: 'Chrome Pinokio/8.0.40' } },
documentRef: { createElement() { assert.fail('unsupported host must allocate no provider DOM'); } },
}),
});
const token = { cancelled: false, signal: new AbortController().signal };
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, mediaPlaybackHoldSec: 7, cameraSettled: true } };
try {
const viewer = eventViewer();
await layer.init(viewer);
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token });
layer.setParams(params, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
const waits = [];
await SceneDirector.prototype._holdShot.call({
_layerStatesForShot: () => ({ [layer.id]: { enabled: true, params } }),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
_effectiveShotHoldSec: () => 7.65,
_sleep: async ms => waits.push(ms),
stopScene: () => assert.fail('a source card must not time out'),
}, { id: 'scene' }, { id: 'shot' }, token);
assert.deepEqual(waits, [7650], 'use the authored dwell instead of completing instantly or polling 20 seconds');
event.evidenceSpine.find(item => item.id === 'mailung-bazzar').media.videoPath = 'fixture-approved.mp4';
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
'an approved local clip remains a real playback owner even with the provider suppressed');
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
});
test('saved autoplay controls cannot authorize media; live shot ownership is cancellable and never serialized', async () => {
const previous = { document: globalThis.document, window: globalThis.window };
installEventDom();
const timers = new Map(); let timerId = 0;
globalThis.window = { setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
const calls = [];
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async url => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
embeddedMediaFactory: () => ({ supportsPlayback: true, warm: () => true,
show: options => { calls.push(options.autoplay); return true; },
getPlaybackState: () => null, play() {}, pause() {}, hide() {}, destroy() {} }),
});
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaPlaybackHoldSec: 7 } };
try {
const viewer = eventViewer(); await layer.init(viewer);
layer.setParams(params, { origin: 'local-restore' }); await layer.enable(viewer);
assert.ok(calls.length); assert.ok(calls.every(value => value === false));
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
const saved = layer.getParams();
const abort = new AbortController();
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false, signal: abort.signal } });
layer.setParams(params, { origin: 'scene' });
assert.equal(calls.at(-1), true);
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
'a temporarily null supported-provider state is still startup, not unsupported');
assert.deepEqual(layer.getParams(), saved, 'live playback authority must not enter saved params');
const late = [...timers.values()];
abort.abort(); late.forEach(fn => fn());
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
layer.setParams(params, { origin: 'local-restore' });
assert.equal(calls.at(-1), false, 'late callbacks and restored flags cannot restart revoked playback');
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'different-shot', token: { cancelled: false } });
layer.setParams(params, { origin: 'scene' });
assert.equal(calls.at(-1), false, 'a different shot cannot borrow this playback owner');
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
});
test('Bhote Koshi timeline helpers clamp untrusted UI values', () => {
assert.equal(clampUnit(-1), 0);
assert.equal(clampUnit(0.42), 0.42);
@@ -1695,7 +1786,9 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
sceneControls: { evidenceMediaAutoplay: true },
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
sceneContext: { sceneId: 'scene', shotId: 'shot' },
}, { origin: 'scene' });
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(videos.length, 1);
const video = videos[0];
@@ -1874,6 +1967,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
sceneSurface: 'evidence-beat',
beatId: 'gyirong-border-gate',
beatReveal: 0.36,
sceneContext: { sceneId: 'scene', shotId: 'shot' },
sceneControls: {
evidenceSequence: true,
evidenceSequenceDurationSec: 0.9,
@@ -1886,6 +1980,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
try {
await layer.init(viewer);
layer.setParams(params, { origin: 'scene' });
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
await layer.enable(viewer, { origin: 'scene' });
const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate');
const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core');
+1
View File
@@ -512,6 +512,7 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
+60 -2
View File
@@ -1009,13 +1009,39 @@ export class SceneDirector {
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
this._interactionTransitions = 0;
return this._trackWork(this._loadShot(sceneId, shotId, options));
const previousGeneration = this._loadGeneration;
const work = this._loadShot(sceneId, shotId, options);
const generation = this._loadGeneration;
const ownsLoad = generation !== previousGeneration;
return this._trackWork(
work.then(
(result) => {
if (
ownsLoad &&
!result?.started &&
generation === this._loadGeneration
)
this._setSceneMediaPlayback();
return result;
},
(error) => {
if (ownsLoad && generation === this._loadGeneration)
this._setSceneMediaPlayback();
throw error;
},
),
);
}
async _loadShot(
sceneId,
shotId,
{ flyDuration = null, fromCamera = null, sceneSeek = null } = {},
{
flyDuration = null,
fromCamera = null,
sceneSeek = null,
playMedia = false,
} = {},
) {
if (this._running) return { started: false, reason: 'already-running' };
const { scene, shot } = this._getShot(sceneId, shotId);
@@ -1034,6 +1060,7 @@ export class SceneDirector {
// manager) rather than merely ignored once it has already committed.
this._cancelActiveSceneTravel();
this._usesAuthoredCamera = !!shot.move;
this._setSceneMediaPlayback();
this._interactions?.clear();
this._dataPacks?.clear();
this._loadAbort?.abort();
@@ -1064,6 +1091,10 @@ export class SceneDirector {
if (token.cancelled) return;
const seekState =
sceneSeek && typeof sceneSeek === 'object' ? sceneSeek : null;
// Previous-scene disable revokes media ownership. Grant the target only
// after release and visual setup have succeeded for this live LOAD.
if (playMedia && !seekState)
this._setSceneMediaPlayback(scene, shot, token);
const layerResult = await this._applyLayerStates(
this._layerStatesForShot(scene, shot, {
cameraSettled: seekState ? seekState.cameraProgress >= 1 : false,
@@ -1159,6 +1190,29 @@ export class SceneDirector {
return layerStatesForShot(scene, shot, this._scenePacks, options);
}
/** Give opt-in media layers a transient, cancellable shot owner. */
_setSceneMediaPlayback(scene = null, shot = null, token = null) {
for (const module of this._sceneMediaModules || [])
module.setSceneMediaPlayback();
this._sceneMediaModules = new Set();
if (!scene || !shot || !token || token.cancelled || token.signal?.aborted)
return;
for (const [id, state] of Object.entries(shot.layers || {})) {
const module = this.dataManager?.layers?.get(id)?.module;
if (
!state?.enabled ||
typeof module?.setSceneMediaPlayback !== 'function'
)
continue;
module.setSceneMediaPlayback({
sceneId: scene.id,
shotId: shot.id,
token,
});
this._sceneMediaModules.add(module);
}
}
/** Keep installed append-pack shots on any surface declared by their source recipe. */
_visualStateForShot(shot) {
return visualStateForShot(
@@ -1314,6 +1368,7 @@ export class SceneDirector {
const previousShot =
scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
const result = await this.loadShot(sceneId, shotId, {
playMedia: true,
flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
fromCamera: shot.move
? null
@@ -1658,6 +1713,7 @@ export class SceneDirector {
// load cannot land a stale shot's layers on top of the run's first shot.
// Aborting cancels a layer transition already in flight; bumping the
// generation disowns everything the load has not yet started.
this._setSceneMediaPlayback();
this._cancelActiveSceneTravel();
this._interactions?.clear();
this._dataPacks?.clear();
@@ -1771,6 +1827,7 @@ export class SceneDirector {
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
*/
stopScene(reason = 'Stopped') {
this._setSceneMediaPlayback();
this._interactions?.clear();
this._dataPacks?.clear();
this._sceneSeekGeneration++;
@@ -2314,6 +2371,7 @@ export class SceneDirector {
* and resets UI buttons to the idle state.
*/
_finishRun() {
this._setSceneMediaPlayback();
this._clock.finish();
this._setPlaybackKeyboardEnabled(false);
// Covers the error path too: a run that threw mid-shot must not leave a
+67
View File
@@ -731,6 +731,73 @@ function makeDirector(options = {}) {
return { director, viewer, styleManager, dataManager, restore };
}
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
const { director, dataManager, restore } = makeDirector();
let owner = null;
const owners = [];
dataManager.layers = new Map([['flights', { module: {
setSceneMediaPlayback(value = null) { owner = value; if (value) owners.push(value.shotId); },
} }]]);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(owner, null, 'passive LOAD is not playback');
await director.replayShot('scene-1', 'shot-a');
assert.equal(owner?.shotId, 'shot-a');
assert.equal(owner.token.cancelled, false);
await director.loadShot('missing-scene', 'missing-shot');
assert.equal(owner?.shotId, 'shot-a', 'rejected navigation does not revoke another action');
director._running = true;
await director.loadShot('scene-1', 'shot-b');
assert.equal(owner?.shotId, 'shot-a', 'LOAD refused during a run cannot stop its media');
director._running = false;
await director.loadShot('scene-1', 'shot-b', { flyDuration: 0 });
assert.equal(owner, null, 'replacement LOAD revokes the prior replay');
await director.replayShot('scene-1', 'shot-a');
director.stopScene();
assert.equal(owner, null);
director._sleep = async () => {};
owners.length = 0;
await director.startScene('scene-1', { single: true, preview: false });
assert.deepEqual(owners, ['shot-a'], 'only enabled opt-in layers receive playback authority');
assert.equal(owner, null, 'run completion releases the last shot owner');
} finally { await director.destroy(); restore(); }
});
test('cross-scene replay grants media ownership only after the previous scene releases its layers', async () => {
const { director, dataManager, restore } = makeDirector();
let owner = null;
const events = [];
const media = { setSceneMediaPlayback(value = null) { owner = value; } };
dataManager.layers = new Map([['flights', { module: media }]]);
const setEnabled = dataManager.setEnabled.bind(dataManager);
dataManager.setEnabled = async (id, enabled, options) => {
if (id === 'flights') {
events.push({ enabled, owner: owner?.shotId ?? null });
if (!enabled) media.setSceneMediaPlayback();
}
return setEnabled(id, enabled, options);
};
try {
const previous = director._project.scenes[0];
previous.releaseLayerIds = ['flights'];
const target = structuredClone(previous);
target.id = 'other-media-scene';
director._project.scenes.push(target);
await director.loadShot(previous.id, 'shot-a', { flyDuration: 0 });
events.length = 0;
const result = await director.replayShot(target.id, 'shot-a');
assert.equal(result.started, true);
assert.deepEqual(events.slice(0, 2), [
{ enabled: false, owner: null },
{ enabled: true, owner: 'shot-a' },
], 'old-scene disable must finish before granting the new owner');
assert.equal(owner?.sceneId, target.id);
assert.equal(owner?.token.cancelled, false);
director.stopScene();
assert.equal(owner, null);
} finally { await director.destroy(); 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;
+1
View File
@@ -30,6 +30,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
);
},
async applyLayers({ scene, shot, token }) {
director._setSceneMediaPlayback(scene, shot, token);
const result = await director._applyLayerStates(
director._layerStatesForShot(scene, shot),
token,