mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1135 from djabhi31/fix/director-scene-results-971
fix(director): keep scene results and save status accurate (#971)
This commit is contained in:
3 files changed
+109
-19
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
- Scenes keep the "Scene not saved" status when a capture, update, append or
|
||||||
|
import cannot be saved, and an unreadable saved project no longer overwrites
|
||||||
|
the recovery checkpoint. `startScene()` resolves to its documented result,
|
||||||
|
`afterShotId` works without an explicit scene, and `scenesRun` counts the
|
||||||
|
distinct scenes queued (djabhi31, #1135).
|
||||||
+24
-13
@@ -189,6 +189,7 @@ export class SceneDirector {
|
|||||||
...this._presentation,
|
...this._presentation,
|
||||||
hasRun: !!this._lastRunJson,
|
hasRun: !!this._lastRunJson,
|
||||||
}));
|
}));
|
||||||
|
if (!this._storageReadError) {
|
||||||
this._bootstrapLegacyShotPacks();
|
this._bootstrapLegacyShotPacks();
|
||||||
// Upgrade only already-installed packs; unrelated Scenes are untouched.
|
// Upgrade only already-installed packs; unrelated Scenes are untouched.
|
||||||
for (const scene of this._project.scenes) {
|
for (const scene of this._project.scenes) {
|
||||||
@@ -206,6 +207,7 @@ export class SceneDirector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this._initUI();
|
this._initUI();
|
||||||
this._visibilityUnsubscribe =
|
this._visibilityUnsubscribe =
|
||||||
this.dataManager.subscribeVisibilityRequests?.((change) => {
|
this.dataManager.subscribeVisibilityRequests?.((change) => {
|
||||||
@@ -324,13 +326,14 @@ export class SceneDirector {
|
|||||||
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
|
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
this._project.updatedAt = new Date().toISOString();
|
this._project.updatedAt = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify(this._project);
|
const payload = JSON.stringify(this._project);
|
||||||
parseSceneDocument(payload);
|
parseSceneDocument(payload);
|
||||||
localStorage.setItem(STORAGE_KEY, payload);
|
localStorage.setItem(STORAGE_KEY, payload);
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
||||||
// in-memory project stays usable this session, but persistence failed —
|
// in-memory project stays usable this session, but persistence failed —
|
||||||
@@ -351,6 +354,7 @@ export class SceneDirector {
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,12 +927,12 @@ export class SceneDirector {
|
|||||||
}
|
}
|
||||||
this._selectedSceneId = scene.id;
|
this._selectedSceneId = scene.id;
|
||||||
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
|
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
|
||||||
this._saveProject();
|
const saved = this._saveProject();
|
||||||
if (render) {
|
if (render) {
|
||||||
this._renderSceneSelect();
|
this._renderSceneSelect();
|
||||||
this._renderShotList();
|
this._renderShotList();
|
||||||
}
|
}
|
||||||
if (announce) {
|
if (announce && saved) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
marker
|
marker
|
||||||
? tn(
|
? tn(
|
||||||
@@ -984,8 +988,9 @@ export class SceneDirector {
|
|||||||
|
|
||||||
scene.shots.push(shot);
|
scene.shots.push(shot);
|
||||||
this._selectedShotId = shot.id;
|
this._selectedShotId = shot.id;
|
||||||
this._saveProject();
|
const saved = this._saveProject();
|
||||||
this._shotOutcome('shot-captured', scene, shot);
|
this._shotOutcome('shot-captured', scene, shot);
|
||||||
|
if (saved) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
t('Captured: {scene} / {shot}', {
|
t('Captured: {scene} / {shot}', {
|
||||||
scene: displaySceneTitle(scene),
|
scene: displaySceneTitle(scene),
|
||||||
@@ -993,6 +998,7 @@ export class SceneDirector {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overwrite the currently selected shot's camera, visual, and layer states
|
* Overwrite the currently selected shot's camera, visual, and layer states
|
||||||
@@ -1017,8 +1023,9 @@ export class SceneDirector {
|
|||||||
shot.visual = this.styleManager.getVisualState();
|
shot.visual = this.styleManager.getVisualState();
|
||||||
shot.layers = this._captureLayerStates();
|
shot.layers = this._captureLayerStates();
|
||||||
|
|
||||||
this._saveProject();
|
const saved = this._saveProject();
|
||||||
this._shotOutcome('shot-updated', scene, shot);
|
this._shotOutcome('shot-updated', scene, shot);
|
||||||
|
if (saved) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
t('Updated: {scene} / {shot}', {
|
t('Updated: {scene} / {shot}', {
|
||||||
scene: displaySceneTitle(scene),
|
scene: displaySceneTitle(scene),
|
||||||
@@ -1026,6 +1033,7 @@ export class SceneDirector {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a specific shot from a scene after user confirmation.
|
* Delete a specific shot from a scene after user confirmation.
|
||||||
@@ -1755,13 +1763,13 @@ export class SceneDirector {
|
|||||||
) {
|
) {
|
||||||
if (this._running) return { started: false, reason: 'already-running' };
|
if (this._running) return { started: false, reason: 'already-running' };
|
||||||
|
|
||||||
let queue = this._buildPlaybackQueue(
|
const startSceneId =
|
||||||
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
|
sceneId || this._selectedSceneId || this._project.scenes[0]?.id;
|
||||||
{ single },
|
let queue = this._buildPlaybackQueue(startSceneId, { single });
|
||||||
);
|
|
||||||
if (afterShotId !== null) {
|
if (afterShotId !== null) {
|
||||||
const index = queue.findIndex(
|
const index = queue.findIndex(
|
||||||
({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
|
({ scene, shot }) =>
|
||||||
|
scene.id === startSceneId && shot.id === afterShotId,
|
||||||
);
|
);
|
||||||
if (index < 0) return { started: false, reason: 'shot-not-found' };
|
if (index < 0) return { started: false, reason: 'shot-not-found' };
|
||||||
queue = queue.slice(index + 1);
|
queue = queue.slice(index + 1);
|
||||||
@@ -1834,7 +1842,7 @@ export class SceneDirector {
|
|||||||
title: 'Editable Scene Run',
|
title: 'Editable Scene Run',
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
estimatedDurationSec,
|
estimatedDurationSec,
|
||||||
scenesRun: queue.length,
|
scenesRun: new Set(queue.map(({ scene }) => scene.id)).size,
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1842,6 +1850,7 @@ export class SceneDirector {
|
|||||||
this._logEvent('scene_run_start', { count: queue.length });
|
this._logEvent('scene_run_start', { count: queue.length });
|
||||||
this._setPlaybackKeyboardEnabled(true);
|
this._setPlaybackKeyboardEnabled(true);
|
||||||
|
|
||||||
|
let runResult = { started: true, shots: queue.length };
|
||||||
try {
|
try {
|
||||||
await playSceneQueue(queue, {
|
await playSceneQueue(queue, {
|
||||||
token,
|
token,
|
||||||
@@ -1860,9 +1869,11 @@ export class SceneDirector {
|
|||||||
this._logEvent('scene_run_error', {
|
this._logEvent('scene_run_error', {
|
||||||
message: error.message || 'unknown error',
|
message: error.message || 'unknown error',
|
||||||
});
|
});
|
||||||
|
runResult = { started: false, reason: 'run-failed' };
|
||||||
} finally {
|
} finally {
|
||||||
this._finishRun();
|
this._finishRun();
|
||||||
}
|
}
|
||||||
|
return runResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2090,9 +2101,9 @@ export class SceneDirector {
|
|||||||
this._selectedShotId =
|
this._selectedShotId =
|
||||||
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
|
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
|
||||||
this._loadedSceneId = null;
|
this._loadedSceneId = null;
|
||||||
this._saveProject();
|
const saved = this._saveProject();
|
||||||
this._publish({ type: 'project-imported', project });
|
this._publish({ type: 'project-imported', project });
|
||||||
this._updateStatus(t('Imported {file}', { file: file.name }));
|
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -601,8 +601,12 @@ const PROJECT_FIXTURE = {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stub the browser globals the director touches, headlessly. */
|
/**
|
||||||
function installSceneRuntime(project = PROJECT_FIXTURE) {
|
* Stub the browser globals the director touches, headlessly. `storage` replaces
|
||||||
|
* the seeded entries (key -> raw string); setting `localStorage.full` makes
|
||||||
|
* every write throw a quota error.
|
||||||
|
*/
|
||||||
|
function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
|
||||||
const originalDocument = globalThis.document;
|
const originalDocument = globalThis.document;
|
||||||
const originalLocalStorage = globalThis.localStorage;
|
const originalLocalStorage = globalThis.localStorage;
|
||||||
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
||||||
@@ -614,10 +618,17 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
|
|||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
body: { classList: noopClassList, appendChild() {} },
|
body: { classList: noopClassList, appendChild() {} },
|
||||||
};
|
};
|
||||||
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
const stored = new Map(storage
|
||||||
|
? Object.entries(storage)
|
||||||
|
: [['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
||||||
globalThis.localStorage = {
|
globalThis.localStorage = {
|
||||||
|
stored,
|
||||||
|
full: false,
|
||||||
getItem: (key) => stored.get(key) ?? null,
|
getItem: (key) => stored.get(key) ?? null,
|
||||||
setItem: (key, value) => stored.set(key, value),
|
setItem(key, value) {
|
||||||
|
if (this.full) throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||||
|
stored.set(key, value);
|
||||||
|
},
|
||||||
removeItem: (key) => stored.delete(key),
|
removeItem: (key) => stored.delete(key),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -718,7 +729,8 @@ async function settle(turns = 8) {
|
|||||||
|
|
||||||
/** Build a director over doubles, with the fixture project loaded. */
|
/** Build a director over doubles, with the fixture project loaded. */
|
||||||
function makeDirector(options = {}) {
|
function makeDirector(options = {}) {
|
||||||
const restore = installSceneRuntime(options.project);
|
const restore = installSceneRuntime(options.project, { storage: options.storage });
|
||||||
|
const storage = globalThis.localStorage;
|
||||||
const viewer = fakeViewer();
|
const viewer = fakeViewer();
|
||||||
const styleManager = fakeStyleManager(options.style);
|
const styleManager = fakeStyleManager(options.style);
|
||||||
const dataManager = fakeDataManager(options.data);
|
const dataManager = fakeDataManager(options.data);
|
||||||
@@ -728,7 +740,7 @@ function makeDirector(options = {}) {
|
|||||||
// Telemetry is only accumulated during a run; observable-failure assertions
|
// Telemetry is only accumulated during a run; observable-failure assertions
|
||||||
// need the accumulator without driving a whole run.
|
// need the accumulator without driving a whole run.
|
||||||
director._activeRun = { events: [] };
|
director._activeRun = { events: [] };
|
||||||
return { director, viewer, styleManager, dataManager, restore };
|
return { director, viewer, styleManager, dataManager, storage, restore };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
|
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
|
||||||
@@ -1584,3 +1596,65 @@ test('camera refusal starts no authored frame or playback clock', async () => {
|
|||||||
assert.deepEqual(viewer.flights, []);
|
assert.deepEqual(viewer.flights, []);
|
||||||
} finally { restore(); }
|
} finally { restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an unreadable saved project does not overwrite its recovery checkpoint (#971)', async () => {
|
||||||
|
const unreadable = '{"version":99,"scenes":[]}';
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
const { director, storage, restore } = makeDirector({ storage: {
|
||||||
|
'godsEyeView.sceneProject.v2': unreadable,
|
||||||
|
'godsEyeView.sceneProject.checkpoint.v1': 'EARLIER-CHECKPOINT',
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
assert.equal(storage.stored.get('godsEyeView.sceneProject.v2'), unreadable);
|
||||||
|
assert.equal(storage.stored.get('godsEyeView.sceneProject.checkpoint.v1'), 'EARLIER-CHECKPOINT');
|
||||||
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a capture, update or import that cannot be saved keeps the "Scene not saved" status (#971)', async () => {
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
const { director, storage, restore } = makeDirector();
|
||||||
|
let status = '';
|
||||||
|
director.subscribe(({ state }) => { status = state.status; });
|
||||||
|
try {
|
||||||
|
storage.full = true;
|
||||||
|
director._selectedSceneId = 'scene-1';
|
||||||
|
director._selectedShotId = 'shot-a';
|
||||||
|
director.captureShot();
|
||||||
|
assert.match(status, /^Scene not saved/, 'failed capture keeps the save failure');
|
||||||
|
director.updateSelectedShot();
|
||||||
|
assert.match(status, /^Scene not saved/, 'failed update keeps the save failure');
|
||||||
|
assert.equal(await director.importProjectFile(
|
||||||
|
{ name: 'fixture.json' },
|
||||||
|
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
|
||||||
|
), true);
|
||||||
|
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
|
||||||
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startScene resolves its result, resumes after a shot without a scene id, and counts distinct scenes (#971)', async () => {
|
||||||
|
const { director, restore } = makeDirector();
|
||||||
|
try {
|
||||||
|
const options = { single: true, preview: false };
|
||||||
|
assert.deepEqual(await director.startScene('scene-1', options), { started: true, shots: 2 });
|
||||||
|
assert.equal(director._lastRun.scenesRun, 1, 'one scene with two shots is one scene run');
|
||||||
|
assert.deepEqual(
|
||||||
|
await director.startScene(undefined, { ...options, afterShotId: 'shot-a' }),
|
||||||
|
{ started: true, shots: 1 },
|
||||||
|
);
|
||||||
|
} finally { await director.destroy(); restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a caught run failure resolves as run-failed, not success (#971)', async () => {
|
||||||
|
const { director, styleManager, restore } = makeDirector();
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
styleManager.applyVisualState = async () => { throw new Error('visual failed'); };
|
||||||
|
try {
|
||||||
|
assert.deepEqual(
|
||||||
|
await director.startScene('scene-1', { single: true, preview: false }),
|
||||||
|
{ started: false, reason: 'run-failed' },
|
||||||
|
);
|
||||||
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||||
|
});
|
||||||
Reference in new issue
Block a user