fix(director): reconcile only stale layers on same-shot seeks

Compare declared visibility with the manager's effective intent, and treat
in-flight transitions as stale so a toggle or superseded LOAD rollback that
is still settling cannot slip past the check. Reconcile only those layers
inside the lightweight seek instead of rebuilding the shot, and report a
refusal without resetting the seek position or camera.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Alex O'NeillandClaude Opus 5.5 committed 2026-10-25 19:54:51 +00:00
1 parent 8553b3d5f7
commit b900fb8410
6 files changed
+115 -29

No files matched your search

+3 -2
View File
@@ -1,3 +1,4 @@
- Restore a shot's declared layer visibility when seeking within it after manual - Restore a shot's declared layer visibility when seeking within it after manual
layer changes. Refused restoration reports a failed seek, while undeclared layer changes, including toggles still in flight. Only changed layers are
operator layers and the unchanged-stack shortcut are preserved (@oneill9, #1374). reconciled; refused restoration reports a failed seek and keeps the last
position, while undeclared operator layers are preserved (@oneill9, #1374).
+4 -3
View File
@@ -507,9 +507,10 @@ and roll the short way for any authored angles.
A seek to a shot's start, as a shot action makes, selects that shot rather than A seek to a shot's start, as a shot action makes, selects that shot rather than
the end of the previous one. the end of the previous one.
Same-shot seeking restores declared layer visibility after a manual toggle, Same-shot seeking restores declared layer visibility after a manual toggle,
using the cancellable layer admission path and reporting refused restoration including one still in flight, by reconciling only the changed layers through
as a failed seek. Undeclared operator layers remain untouched, and unchanged the cancellable layer admission path. Refused restoration reports a failed
declared visibility retains the lightweight seek path. seek and keeps the last settled position. Undeclared operator layers remain
untouched, and the shot is not rebuilt.
See [camera directions](DIRECTOR-CAMERA.md). See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input Realtime voice composes separate connection, response/tool, Radio handoff, input
+7 -2
View File
@@ -122,10 +122,15 @@ try {
const loaded = await director.loadShot(scene.id, scene.shots[0].id, { const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.2, flyDuration: 0.2,
}); });
await dataManager.setEnabled(layerId, false, { origin: 'user' }); const disabled =
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
!dataManager.layers.get(layerId).enabled;
const sought = await director.seekScene(scene.id, 0.5); const sought = await director.seekScene(scene.id, 0.5);
return ( return (
loaded?.started && sought && dataManager.layers.get(layerId).enabled loaded?.started &&
disabled &&
sought &&
dataManager.layers.get(layerId).enabled
); );
} finally { } finally {
director.stopScene(); director.stopScene();
@@ -188,7 +188,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
_claimCameraOwnership: () => _claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'), assert.fail('Must reload the released pack before camera seek'),
}); });
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false); assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
}); });
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => { test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
+39 -18
View File
@@ -1516,9 +1516,13 @@ export class SceneDirector {
return new Promise((resolve) => this._runIdleResolvers.add(resolve)); return new Promise((resolve) => this._runIdleResolvers.add(resolve));
} }
/** Apply a new time inside the already-loaded shot without rebuilding its stack. */ /**
_seekLoadedShot(scene, seekState) { * Apply a new time inside the already-loaded shot without rebuilding its stack.
if (this._destroyed) return false; * @returns {Promise<boolean|null>} Null when the shortcut does not apply and
* LOAD must rebuild the shot; otherwise whether the seek settled.
*/
async _seekLoadedShot(scene, seekState) {
if (this._destroyed) return null;
const shot = seekState?.shot; const shot = seekState?.shot;
if ( if (
!scene || !scene ||
@@ -1528,28 +1532,41 @@ export class SceneDirector {
this._loadedSceneId !== scene.id || this._loadedSceneId !== scene.id ||
this._selectedShotId !== shot.id this._selectedShotId !== shot.id
) )
return false; return null;
const states = this._layerStatesForShot(scene, shot, { const states = this._layerStatesForShot(scene, shot, {
cameraSettled: seekState.cameraProgress >= 1, cameraSettled: seekState.cameraProgress >= 1,
sceneSeek: seekState, sceneSeek: seekState,
}); });
const layers = new Map( // A manual toggle, or a superseded LOAD transition whose rollback is still
this.dataManager.getAll().map((layer) => [layer.id, layer.enabled]), // settling, leaves declared layers off the shot's stack. Reconcile only
// those through the cancellable admission path.
const staleStates = Object.fromEntries(
Object.entries(states).filter(([id, state]) => {
const lifecycle = this.dataManager.getLayerLifecycleState(id);
return (
lifecycle &&
(lifecycle.lifecycleState === 'enabling' ||
lifecycle.lifecycleState === 'disabling' ||
this.dataManager.isEffectivelyEnabled(id) !== !!state?.enabled)
);
}),
); );
// A manual toggle invalidates the loaded stack. Let LOAD reconcile it if (!this._claimCameraOwnership()) return null;
// through the normal cancellable admission path before reporting success.
if (
sceneLayerPlan(states, new Set(layers.keys())).some(
({ id, enabled }) => Boolean(layers.get(id)) !== enabled,
)
)
return false;
if (!this._claimCameraOwnership()) return false;
this._cancelActiveSceneTravel(); this._cancelActiveSceneTravel();
this._loadAbort?.abort(); this._loadAbort?.abort();
this._loadAbort = null; this._loadAbort = null;
this._loadGeneration += 1; const generation = ++this._loadGeneration;
this._clock.stopShot(); this._clock.stopShot();
if (Object.keys(staleStates).length) {
const controller = new AbortController();
this._loadAbort = controller;
const layerResult = await this._applyLayerStates(
staleStates,
this._loadToken(generation, controller.signal),
);
if (this._loadAbort === controller) this._loadAbort = null;
if (layerResult.cancelled || layerResult.refused.length) return false;
}
let applied = true; let applied = true;
for (const [layerId, state] of Object.entries(states)) { for (const [layerId, state] of Object.entries(states)) {
if (!state?.enabled || !state.params) continue; if (!state?.enabled || !state.params) continue;
@@ -1561,7 +1578,7 @@ export class SceneDirector {
applied = false; applied = false;
} }
} }
if (!applied) return false; if (!applied) return null;
this._setCameraView( this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera), seekState.camera || resolveCameraPose(scene, shot.camera),
); );
@@ -1597,7 +1614,11 @@ export class SceneDirector {
return false; return false;
const seekState = this._sceneSeekState(scene, progress); const seekState = this._sceneSeekState(scene, progress);
if (!seekState) return false; if (!seekState) return false;
if (this._seekLoadedShot(scene, seekState)) return true; const seeked = await this._seekLoadedShot(scene, seekState);
if (seeked !== null)
return (
seeked && !this._destroyed && generation === this._sceneSeekGeneration
);
const result = await this._loadShot(scene.id, seekState.shot.id, { const result = await this._loadShot(scene.id, seekState.shot.id, {
sceneSeek: seekState, sceneSeek: seekState,
}); });
+61 -3
View File
@@ -175,7 +175,7 @@ test('scene clock seek resolves the exact shot phase and camera in both directio
}); });
test('same-shot seeking restores declared layer visibility after manual changes', async () => { test('same-shot seeking restores declared layer visibility after manual changes', async () => {
const { director, dataManager, restore } = makeDirector(); const { director, dataManager, styleManager, restore } = makeDirector();
try { try {
const shot = director._project.scenes[0].shots[0]; const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } }; shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
@@ -184,8 +184,11 @@ test('same-shot seeking restores declared layer visibility after manual changes'
await dataManager.setEnabled('traffic', true); await dataManager.setEnabled('traffic', true);
await dataManager.setEnabled('satellites', true); await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0; dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true); assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled])); const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer'); assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer'); assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
@@ -199,17 +202,58 @@ test('same-shot seeking restores declared layer visibility after manual changes'
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => { test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
let refuse = false; let refuse = false;
const { director, dataManager, restore } = makeDirector({ const { director, dataManager, styleManager, restore } = makeDirector({
data: { refuse: () => refuse }, data: { refuse: () => refuse },
}); });
try { try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false); await dataManager.setEnabled('flights', false);
refuse = true; refuse = true;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false); assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false); assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
assert.equal(director.getPlaybackTimingState().activeTimers, 0); const timing = director.getPlaybackTimingState();
assert.equal(timing.activeTimers, 0);
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
const { director, dataManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
const disabling = dataManager.setEnabled('flights', false);
assert.equal(await director.seekScene('scene-1', 0.25), true);
await disabling;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
const { director, dataManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
dataManager.setEnabledCalls.length = 0;
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal(await director.seekScene('scene-1', 0.25), true);
await reload;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
} finally { } finally {
await director.destroy(); await director.destroy();
restore(); restore();
@@ -707,6 +751,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
*/ */
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) { function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
const enabled = new Map(); const enabled = new Map();
const pending = new Map();
const setEnabledCalls = []; const setEnabledCalls = [];
const setParamsCalls = []; const setParamsCalls = [];
const committed = []; const committed = [];
@@ -719,12 +764,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
setParamsCalls, setParamsCalls,
committed, committed,
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })), getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
getLayerLifecycleState: (id) => {
if (!registered.includes(id)) return null;
const on = !!enabled.get(id);
const target = pending.get(id);
return {
enabled: on,
lifecycleState:
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
};
},
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
getLayerParams: () => null, getLayerParams: () => null,
async setEnabled(id, shouldEnable, { signal } = {}) { async setEnabled(id, shouldEnable, { signal } = {}) {
setEnabledCalls.push({ id, enabled: shouldEnable, signal }); setEnabledCalls.push({ id, enabled: shouldEnable, signal });
if (refuse(id, shouldEnable)) return false; if (refuse(id, shouldEnable)) return false;
// Yield once so a stop landing during the transition is observable. // Yield once so a stop landing during the transition is observable.
pending.set(id, shouldEnable);
await Promise.resolve(); await Promise.resolve();
if (pending.get(id) === shouldEnable) pending.delete(id);
if (signal?.aborted) return false; if (signal?.aborted) return false;
enabled.set(id, shouldEnable); enabled.set(id, shouldEnable);
committed.push({ id, enabled: shouldEnable }); committed.push({ id, enabled: shouldEnable });