mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: drive share, location and scene controls through product state
This commit is contained in:
1 parent
21a0d0c74e
commit
45d97376d0
21 files changed
+606
-96
No files matched your search
+59
-23
@@ -12,6 +12,7 @@
|
||||
* State is persisted to localStorage and can be exported/imported as JSON.
|
||||
*/
|
||||
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { SceneControls } from '../ui/scenes.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { SCENE_RECIPES } from './recipes.js';
|
||||
@@ -315,6 +316,8 @@ export class SceneDirector {
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
||||
|
||||
this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false };
|
||||
this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson }));
|
||||
this._initUI();
|
||||
}
|
||||
|
||||
@@ -332,6 +335,7 @@ export class SceneDirector {
|
||||
if (this._destroyPromise) return this._destroyPromise;
|
||||
this._destroyed = true;
|
||||
this._controls?.destroy();
|
||||
this._state.destroy();
|
||||
this._destroyPromise = Promise.resolve().then(async () => {
|
||||
this.stopScene('Stopped');
|
||||
this._loadAbort?.abort();
|
||||
@@ -394,8 +398,18 @@ export class SceneDirector {
|
||||
* Wire up all scene-panel DOM event listeners and render the initial UI state.
|
||||
* Exits silently if the scene-select element is missing (headless/test mode).
|
||||
*/
|
||||
/** Immutable playback snapshots and completed editing actions. */
|
||||
subscribe(listener, options) { return this._state.subscribe(listener, options); }
|
||||
|
||||
_publish(change) { this._state?.publish(change); }
|
||||
|
||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||
this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index });
|
||||
}
|
||||
|
||||
_initUI() {
|
||||
this._controls = new SceneControls({
|
||||
subscribe: (listener) => this.subscribe(listener),
|
||||
read: () => ({
|
||||
scenes: this._project.scenes,
|
||||
selectedSceneId: this._selectedSceneId,
|
||||
@@ -409,13 +423,13 @@ export class SceneDirector {
|
||||
this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
|
||||
this._renderShotList();
|
||||
},
|
||||
selectShot: (id) => { this._selectedShotId = id; },
|
||||
selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); },
|
||||
renameShot: (sceneId, shotId, title) => {
|
||||
const { shot } = this._getShot(sceneId, shotId);
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
if (!shot) return;
|
||||
shot.title = title.trim() || shot.title;
|
||||
this._saveProject();
|
||||
this._renderShotList();
|
||||
this._shotOutcome('shot-renamed', scene, shot);
|
||||
},
|
||||
create: (name) => this._createScene(name),
|
||||
deleteScene: () => this._deleteSelectedScene(),
|
||||
@@ -438,7 +452,7 @@ export class SceneDirector {
|
||||
if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) {
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
}
|
||||
this._controls?.renderSceneSelect();
|
||||
this._publish({ type: 'scene-options-changed' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,7 +465,7 @@ export class SceneDirector {
|
||||
if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) {
|
||||
this._selectedShotId = scene.shots[0].id;
|
||||
}
|
||||
this._controls?.renderShotList();
|
||||
this._publish({ type: 'shots-changed' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,8 +502,7 @@ export class SceneDirector {
|
||||
this._selectedSceneId = scene.id;
|
||||
this._selectedShotId = null;
|
||||
this._saveProject();
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
this._publish({ type: 'scene-created', scene });
|
||||
}
|
||||
|
||||
/** Delete the currently selected scene after user confirmation. Resets to defaults if empty. */
|
||||
@@ -506,8 +519,7 @@ export class SceneDirector {
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
||||
this._saveProject();
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
this._publish({ type: 'scene-deleted', scene });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,7 +565,7 @@ export class SceneDirector {
|
||||
scene.shots.push(shot);
|
||||
this._selectedShotId = shot.id;
|
||||
this._saveProject();
|
||||
this._renderShotList();
|
||||
this._shotOutcome('shot-captured', scene, shot);
|
||||
this._updateStatus(`Captured: ${scene.title} / ${shot.title}`);
|
||||
}
|
||||
|
||||
@@ -579,7 +591,7 @@ export class SceneDirector {
|
||||
shot.layers = this._captureLayerStates();
|
||||
|
||||
this._saveProject();
|
||||
this._renderShotList();
|
||||
this._shotOutcome('shot-updated', scene, shot);
|
||||
this._updateStatus(`Updated: ${scene.title} / ${shot.title}`);
|
||||
}
|
||||
|
||||
@@ -592,10 +604,11 @@ export class SceneDirector {
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
if (!scene || !shot) return;
|
||||
|
||||
const index = scene.shots.indexOf(shot);
|
||||
scene.shots = scene.shots.filter((item) => item.id !== shot.id);
|
||||
this._selectedShotId = scene.shots[0]?.id || null;
|
||||
this._saveProject();
|
||||
this._renderShotList();
|
||||
this._shotOutcome('shot-deleted', scene, shot, index);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,7 +659,7 @@ export class SceneDirector {
|
||||
if (token.cancelled) return;
|
||||
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
this._updateStatus(`Loaded: ${scene.title} / ${shot.title}`);
|
||||
this._shotOutcome('shot-loaded', scene, shot);
|
||||
this._updateRuntime('');
|
||||
}
|
||||
|
||||
@@ -750,6 +763,9 @@ export class SceneDirector {
|
||||
return {
|
||||
running: this._running,
|
||||
selectedSceneId: this._selectedSceneId,
|
||||
selectedShotId: this._selectedShotId,
|
||||
elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null,
|
||||
estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null,
|
||||
sceneCount: this._project.scenes.length,
|
||||
};
|
||||
}
|
||||
@@ -806,7 +822,7 @@ export class SceneDirector {
|
||||
|
||||
// Transition to running state
|
||||
this._running = true;
|
||||
this._controls?.setPlaybackActive(true);
|
||||
this._setPlaybackActive(true);
|
||||
this._setButtons(true);
|
||||
this._setProgress(0);
|
||||
|
||||
@@ -841,7 +857,7 @@ export class SceneDirector {
|
||||
|
||||
this._startProgressTicker(estimatedDurationSec || 1);
|
||||
this._logEvent('scene_run_start', { count: queue.length });
|
||||
this._controls?.setPlaybackKeyboardEnabled(true);
|
||||
this._setPlaybackKeyboardEnabled(true);
|
||||
|
||||
try {
|
||||
// Main shot sequencing loop
|
||||
@@ -963,6 +979,7 @@ export class SceneDirector {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this._publish({ type: 'project-exported', project: this._project });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -979,8 +996,7 @@ export class SceneDirector {
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
||||
this._saveProject();
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
this._publish({ type: 'project-imported', project: this._project });
|
||||
this._updateStatus(`Imported ${file.name}`);
|
||||
} catch {
|
||||
this._updateStatus('Import failed (invalid JSON)');
|
||||
@@ -1197,14 +1213,14 @@ export class SceneDirector {
|
||||
_finishRun() {
|
||||
clearInterval(this._progressTimer);
|
||||
this._progressTimer = null;
|
||||
this._controls?.setPlaybackKeyboardEnabled(false);
|
||||
this._setPlaybackKeyboardEnabled(false);
|
||||
// Covers the error path too: a run that threw mid-shot must not leave a
|
||||
// layer transition running against a director that has stopped watching.
|
||||
this._runAbort?.abort();
|
||||
this._runAbort = null;
|
||||
|
||||
this.styleManager.setRecordingMode(false);
|
||||
this._controls?.setPlaybackActive(false);
|
||||
this._setPlaybackActive(false);
|
||||
this._updateRuntime('');
|
||||
this._running = false;
|
||||
|
||||
@@ -1226,25 +1242,44 @@ export class SceneDirector {
|
||||
* Editing controls are disabled during a run; stop is disabled when idle.
|
||||
* @param {boolean} isRunning
|
||||
*/
|
||||
_setButtons(isRunning) { this._controls?.setButtons(isRunning); }
|
||||
_setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); }
|
||||
|
||||
_setPlaybackActive(active) {
|
||||
this._presentation.playbackActive = active;
|
||||
this._publish({ type: 'playback-presentation' });
|
||||
}
|
||||
|
||||
_setPlaybackKeyboardEnabled(enabled) {
|
||||
this._presentation.keyboardEnabled = enabled;
|
||||
this._publish({ type: 'playback-keyboard' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress bar fill width and label.
|
||||
* @param {number} progress - Value in [0, 1]
|
||||
*/
|
||||
_setProgress(progress) { this._controls?.setProgress(progress); }
|
||||
_setProgress(progress) {
|
||||
this._presentation.progress = progress;
|
||||
this._publish({ type: 'progress-changed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the status line text in the scene panel.
|
||||
* @param {string} text
|
||||
*/
|
||||
_updateStatus(text) { this._controls?.updateStatus(text); }
|
||||
_updateStatus(text) {
|
||||
this._presentation.status = text;
|
||||
this._publish({ type: 'status-changed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the runtime label (scene/shot name) and toggle its active class.
|
||||
* @param {string} text - Empty string hides the label
|
||||
*/
|
||||
_updateRuntime(text) { this._controls?.updateRuntime(text); }
|
||||
_updateRuntime(text) {
|
||||
this._presentation.runtime = text;
|
||||
this._publish({ type: 'runtime-changed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a timestamped telemetry event to the active run log.
|
||||
@@ -1253,6 +1288,7 @@ export class SceneDirector {
|
||||
*/
|
||||
_logEvent(type, payload) {
|
||||
if (!this._activeRun) return;
|
||||
this._publish({ type: 'run-event', event: type, detail: payload || null });
|
||||
this._activeRun.events.push({
|
||||
t: new Date().toISOString(),
|
||||
type,
|
||||
|
||||
@@ -645,3 +645,55 @@ test('destroy drains cancelled LOAD work before its viewer can be discarded', as
|
||||
assert.equal((await director.startScene('scene-1')).reason, 'destroyed');
|
||||
await director.destroy();
|
||||
});
|
||||
|
||||
test('Scene snapshots are immutable and editing outcomes retain the affected shot and index', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
director._activeRun = null;
|
||||
const seen = [];
|
||||
const unsubscribe = director.subscribe((notification) => seen.push(notification));
|
||||
assert.equal(seen[0].initial, true);
|
||||
assert.equal(seen[0].state.selectedShotId, 'shot-a');
|
||||
assert.ok(Object.isFrozen(seen[0].state));
|
||||
director.captureShot();
|
||||
const captured = seen.find(({ change }) => change?.type === 'shot-captured');
|
||||
assert.equal(captured.change.index, 2);
|
||||
assert.ok(Object.isFrozen(captured.change.shot.camera));
|
||||
const id = captured.change.shot.id;
|
||||
director.deleteShot('scene-1', id);
|
||||
const deleted = seen.find(({ change }) => change?.type === 'shot-deleted');
|
||||
assert.equal(deleted.change.index, 2);
|
||||
assert.equal(deleted.change.shot.id, id);
|
||||
assert.equal(captured.state.selectedShotId, id);
|
||||
unsubscribe();
|
||||
const count = seen.length;
|
||||
director.captureShot();
|
||||
assert.equal(seen.length, count);
|
||||
await director.destroy();
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('Scene load outcomes exclude superseded and disposed completions', async () => {
|
||||
const { director, styleManager, restore } = makeDirector();
|
||||
try {
|
||||
director._activeRun = null;
|
||||
const seen = [];
|
||||
director.subscribe(({ change }) => { if (change) seen.push(change); });
|
||||
const pending = [];
|
||||
styleManager.applyVisualState = () => new Promise((resolve) => pending.push(resolve));
|
||||
const old = director.loadShot('scene-1', 'shot-a');
|
||||
const current = director.loadShot('scene-1', 'shot-b');
|
||||
pending[1]();
|
||||
await current;
|
||||
pending[0]();
|
||||
await old;
|
||||
assert.deepEqual(seen.filter((change) => change.type === 'shot-loaded').map((change) => change.shot.id), ['shot-b']);
|
||||
const late = director.loadShot('scene-1', 'shot-a');
|
||||
const disposal = director.destroy();
|
||||
const count = seen.length;
|
||||
pending[2]();
|
||||
await Promise.all([late, disposal]);
|
||||
director.subscribe(() => assert.fail('disposed director must not notify'));
|
||||
assert.equal(seen.length, count);
|
||||
} finally { restore(); }
|
||||
});
|
||||
Reference in new issue
Block a user