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

+3
View File
@@ -3,6 +3,9 @@
- Keep Nepal provider media inside the Pinokio compatibility boundary: use
source-linked fallback cards instead of automatic embeds or hidden preloads
that launch an external browser. Ordinary browsers retain embedded playback.
Source-only timed shots use their authored card dwell and continue normally.
Media autoplay now requires a live Play Scene or Play Shot action; passive
loading, saved state and seeking do not grant playback authority.
- Add Director import previews, validated scene/shot detail drafts and selected-scene
JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes
+1 -1
View File
@@ -87,7 +87,7 @@ The Nepal overview route and lake-shot trail share the river-centerline starting
The event panel omits the WITNESS shortcut. Shot media, source attribution, Open Original links and corridor navigation remain available.
The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. The authored estimate remains seven seconds plus the exit, replacing older saved holds at runtime without rewriting them. Stop, replacement and teardown revoke pending playback callbacks.
The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. When the host deliberately suppresses provider playback and there is no approved local clip, the source card instead uses the authored 7.65-second dwell before the next shot. The authored estimate replaces older saved holds at runtime without rewriting them. Autoplay requires transient Director ownership from Play Scene or Play Shot; LOAD, restoration and seek remain passive. Stop, replacement and teardown revoke that ownership and pending playback callbacks.
Persistent media-led Nepal shots preserve their completed upstream flood path during camera travel. The next reach still reveals after arrival; direct loads reconstruct the prefix and backward replay trims downstream history.
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env node
// Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture,
// not native-host acceptance; the YouTube SDK clock below is deterministic.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve(
process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media',
);
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL !== '1',
args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [],
});
let assertions = 0;
const check = (name, value) => {
assert.ok(value, name);
assertions++;
console.log(`[PASS] ${name}`);
};
try {
for (const pinokio of [true, false]) {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
if (pinokio)
await page.setUserAgent((await browser.userAgent()) + ' Pinokio/8.0.40');
const popups = [];
page.on('popup', (popup) => popups.push(popup.url()));
// Do not depend on provider availability to prove our startup/trim/exit handoff.
await page.setRequestInterception(true);
page.on('request', (request) => {
if (/youtube(?:-nocookie)?\.com\/embed\//.test(request.url())) {
void request.respond({
status: 200,
contentType: 'text/html',
body: '<p>Player clock fixture</p>',
});
} else void request.continue();
});
await page.evaluateOnNewDocument(() => {
window.__mediaFixture = { starts: [], stops: [] };
window.YT = {
Player: class {
constructor(_frame, { events }) {
this.events = events;
this.began = null;
this.stoppedAt = null;
this.timer = setTimeout(
() => events.onReady({ target: this }),
1200,
);
}
mute() {}
playVideo() {
if (this.began != null) return;
this.began = Date.now();
window.__mediaFixture.starts.push(this.began);
this.events.onStateChange({ target: this, data: 1 });
}
getCurrentTime() {
return this.began == null
? 0
: ((this.stoppedAt || Date.now()) - this.began) / 1000;
}
getPlayerState() {
return this.began == null ? -1 : this.stoppedAt ? 2 : 1;
}
pauseVideo() {
if (this.began != null && !this.stoppedAt) {
this.stoppedAt = Date.now();
window.__mediaFixture.stops.push(this.getCurrentTime());
}
}
destroy() {
clearTimeout(this.timer);
this.pauseVideo();
}
},
};
});
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.querySelector('#loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async (pinokio) => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find((s) => /Nepal/.test(s.title));
const shot = scene.shots.find((s) => /Mailung Bazzar/.test(s.title));
const following = scene.shots[scene.shots.indexOf(shot) + 1];
const previous = scene.shots[scene.shots.indexOf(shot) - 1];
const frames = () =>
[...document.querySelectorAll('iframe')].map((f) => f.src);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
const openingFrames = frames();
await d.loadShot(scene.id, shot.id, { flyDuration: 0.1 });
const passiveFrames = frames();
const started = Date.now();
const run = d.startScene(scene.id, {
single: true,
afterShotId: previous.id,
preview: false,
});
let advanced = null;
while (Date.now() - started < 25000) {
if (d._selectedShotId === following.id) {
advanced = Date.now() - started;
break;
}
if (!d.running) break;
await sleep(100);
}
const runningAtAdvance = d.running;
const clipStops = [...window.__mediaFixture.stops];
const status = document.querySelector('#scene-status')?.textContent;
d.stopScene();
await run;
await sleep(250);
const stoppedFrames = frames();
let replayAutoplay = false;
let replacementSilent = false;
if (!pinokio) {
await d.replayShot(scene.id, shot.id);
replayAutoplay = frames().some((src) => /autoplay=1/.test(src));
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
const starts = window.__mediaFixture.starts.length;
await sleep(1600);
replacementSilent =
frames().length === 0 &&
starts === window.__mediaFixture.starts.length;
}
const gl = d.viewer.scene.context._gl;
const info = gl.getExtension('WEBGL_debug_renderer_info');
return {
openingFrames,
passiveFrames,
advanced,
runningAtAdvance,
status,
clipStops,
stoppedFrames,
replayAutoplay,
replacementSilent,
renderer: info
? gl.getParameter(info.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER),
};
}, pinokio);
console.log(JSON.stringify({ pinokioPolicyFixture: pinokio, ...result }));
check(
'opening shot creates no unrelated provider frames',
result.openingFrames.length === 0,
);
check(
'passive LOAD never requests autoplay',
result.passiveFrames.every((src) => !/autoplay=1/.test(src)),
);
check(
'actual next-shot identity reached while scene remains running',
result.advanced > 0 && result.runningAtAdvance,
);
check('no scene timeout', !/timed out|Error:/i.test(result.status || ''));
check('Stop removes provider resources', result.stoppedFrames.length === 0);
check('no automatic external window', popups.length === 0);
if (pinokio) {
check(
'suppressed providers allocate no iframe',
result.passiveFrames.length === 0,
);
check(
'fallback keeps authored dwell',
result.advanced >= 7500 && result.advanced < 18000,
);
} else {
check(
'supported clip follows seven-second source clock',
result.clipStops.some((sec) => sec >= 7 && sec < 7.6),
);
check(
'explicit Play Shot still requests autoplay',
result.replayAutoplay,
);
check(
'replacement disowns pending player startup',
result.replacementSilent,
);
}
await page.screenshot({
path: path.join(output, pinokio ? 'pinokio-policy.png' : 'chrome.png'),
});
await page.close();
}
console.log(`PASS: ${assertions} assertions`);
} finally {
await browser.close();
}
+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,