refactor: separate Scene controls from playback and project operations

This commit is contained in:
Sameh Khamis committed 2026-09-13 03:29:46 -07:00
1 parent 8b5ad67dcb
commit cd72feb4fe
12 files changed
+566 -222

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [Unreleased]
- Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback.
- Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations. - Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations.
- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown. - Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown.
+7
View File
@@ -320,3 +320,10 @@ cache and sampling operations, continuous-render owner and regional briefing
service. Pure math, utility layout and vision helpers have explicit exports. service. Pure math, utility layout and vision helpers have explicit exports.
Disposal releases subscriptions and pending work; portal moves preserve the Disposal releases subscriptions and pending work; portal moves preserve the
original Display groups, independent scroll positions and current focus owner. original Display groups, independent scroll positions and current focus owner.
## Scene controls
`ui/scenes` owns Scene panel input, project/shot rows, playback button and runtime
presentation. It receives project reads and explicit actions, with no imports of
the director, source modules, camera engine or storage. Replacement and disposal
release listeners; pending action feedback is limited to its current owner.
+8
View File
@@ -1,5 +1,13 @@
# God's Eye View Current State # God's Eye View Current State
## Scene control ownership
Scene controls own panel listeners, shot rows, playback/recording presentation
and keyboard cancellation. The director supplies project reads and explicit
editing/playback actions while retaining persistence, camera and layer sequencing.
Replacing rows revokes their old listeners. Disposal stops controls immediately;
late file and failed-action completions cannot update removed presentation.
## Cockpit component ownership ## Cockpit component ownership
Cockpit presentation is separated from its camera/controller behavior. Existing Cockpit presentation is separated from its camera/controller behavior. Existing
+2 -1
View File
@@ -137,6 +137,7 @@
"./ui/cockpit": "./src/ui/cockpit.js", "./ui/cockpit": "./src/ui/cockpit.js",
"./ui/cockpit/math": "./src/cockpitMath.js", "./ui/cockpit/math": "./src/cockpitMath.js",
"./ui/cockpit/layout": "./src/cockpitUtilityLayout.js", "./ui/cockpit/layout": "./src/cockpitUtilityLayout.js",
"./ui/cockpit/vision": "./src/cockpitVisionPolicy.js" "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js",
"./ui/scenes": "./src/ui/scenes.js"
} }
} }
+9
View File
@@ -414,5 +414,14 @@
"src/ui/panelRailGeometry.js" "src/ui/panelRailGeometry.js"
], ],
"external": ["cesium"] "external": ["cesium"]
},
"scene-controls": {
"exports": ["./ui/scenes"],
"modules": [
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js"
],
"external": []
} }
} }
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/** Exercise installed Scene controls and real project/playback operations. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const shots = path.resolve('qa-shots/scene-controls');
fs.mkdirSync(shots, { recursive: true });
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let promptValue = 'QA Scene';
page.on('dialog', async (dialog) => { await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); });
let failures = 0;
function check(name, ok) { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; }
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.sceneDirector?._controls && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
await page.click('[data-collapse-target="scene-panel"]');
await page.click('#scene-new-btn');
await page.waitForFunction(() => document.querySelector('#scene-select option:checked')?.textContent === 'QA Scene');
check('New creates and selects a persisted empty scene', await page.evaluate(() => {
const director = window.__godsEyeView.sceneDirector;
const saved = JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2'));
return director._getSelectedScene().shots.length === 0 && !!document.querySelector('.scene-shot-empty') && saved.scenes.some((scene) => scene.title === 'QA Scene');
}));
await page.click('#scene-capture-btn');
await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 1);
check('Capture stores the actual camera and visual state', await page.evaluate(() => {
const director = window.__godsEyeView.sceneDirector;
const shot = director._getSelectedScene().shots[0];
const current = director.styleManager.getCameraState();
return Number.isFinite(shot.camera.lat) && Math.abs(shot.camera.lat - current.lat) < 0.01 && shot.visual.style === director.styleManager.getVisualState().style;
}));
promptValue = '<b>QA shot</b>';
await page.click('.scene-shot-label', { clickCount: 2 });
await page.waitForFunction(() => document.querySelector('.scene-shot-label')?.textContent === '<b>QA shot</b>');
check('Rename persists literal text without interpreting markup', await page.evaluate(() => !document.querySelector('.scene-shot-label b') && JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((scene) => scene.shots.some((shot) => shot.title === '<b>QA shot</b>'))));
await page.click('#scene-capture-btn');
await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 2);
await page.click('.scene-shot-label');
check('Shot selection and Update use the selected shot', await page.evaluate(() => {
const director = window.__godsEyeView.sceneDirector;
const scene = director._getSelectedScene();
const count = scene.shots.length;
document.getElementById('scene-update-shot-btn').click();
return director._selectedShotId === scene.shots[0].id && scene.shots.length === count && document.querySelector('.scene-shot-row.active .scene-shot-label').textContent === scene.shots[0].title;
}));
await page.evaluate(() => {
window.__sceneExports = [];
const create = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
if (blob.type === 'application/json') window.__sceneExports.push(blob.text());
return create(blob);
};
});
await page.click('#scene-export-btn');
const exported = await page.evaluate(async () => JSON.parse(await window.__sceneExports[0]));
check('Export produces the current project JSON', exported.scenes.some((scene) => scene.title === 'QA Scene' && scene.shots.length === 2));
// Keep real camera/style playback while isolating this control check from
// live provider availability. Layer transactions are covered by their suites.
const scene = exported.scenes.find((item) => item.title === 'QA Scene');
scene.shots = scene.shots.slice(0, 1).map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} }));
const fixture = { ...exported, scenes: [scene] };
const projectFile = path.join(shots, 'project.json');
fs.writeFileSync(projectFile, JSON.stringify(fixture));
const input = await page.$('#scene-import-file');
await input.uploadFile(projectFile);
await page.waitForFunction(() => document.getElementById('scene-status').textContent === 'Imported project.json');
check('Import replaces the project and clears the file input', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && document.querySelectorAll('.scene-shot-row').length === 1 && document.getElementById('scene-import-file').value === ''));
await page.click('.scene-shot-btn');
await page.waitForFunction(() => document.getElementById('scene-status').textContent.startsWith('Loaded:'), { timeout: 30000 });
check('LOAD completes the real visual and camera operation', await page.evaluate(() => !window.__godsEyeView.sceneDirector.running && document.getElementById('scene-status').textContent.includes('<b>QA shot</b>')));
await page.click('#scene-start-btn');
await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running && document.body.classList.contains('scene-playback-mode'));
check('Playback enters recording presentation and disables editing', await page.evaluate(() => document.getElementById('scene-capture-btn').disabled && document.getElementById('scene-start-btn').disabled && !document.getElementById('scene-stop-btn').disabled && window.__godsEyeView.styleManager.getViewState().recording));
await page.keyboard.press('Escape');
await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running && !document.body.classList.contains('scene-playback-mode'));
check('Escape ends playback and restores idle/recording controls', await page.evaluate(() => !document.getElementById('scene-start-btn').disabled && document.getElementById('scene-stop-btn').disabled && !document.getElementById('scene-download-btn').disabled && !window.__godsEyeView.styleManager.getViewState().recording));
await page.click('#scene-download-btn');
const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1)));
check('Run download contains the completed cancelled run', Boolean(metadata.endedAt && metadata.wasCancelled && Array.isArray(metadata.events)));
await page.click('#scene-start-btn');
await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running);
await page.$eval('#scene-stop-btn', (button) => button.click());
await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running);
check('The installed Stop control also completes playback cleanup', await page.evaluate(() => !document.body.classList.contains('scene-playback-mode') && !document.getElementById('scene-start-btn').disabled));
const badFile = path.join(shots, 'invalid.json');
fs.writeFileSync(badFile, '{invalid');
await input.uploadFile(badFile);
await page.waitForFunction(() => document.getElementById('scene-status').textContent.includes('invalid JSON'));
check('Invalid import reports failure and preserves the current project', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && window.__godsEyeView.sceneDirector._getSelectedScene().title === 'QA Scene'));
await page.screenshot({ path: path.join(shots, 'desktop.png') });
await page.setViewport({ width: 390, height: 844 });
await page.screenshot({ path: path.join(shots, 'narrow.png') });
check('Scene controls stay inside the narrow viewport', await page.$eval('#scene-select', (element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; }));
await page.setViewport({ width: 1440, height: 900 });
await page.click('.scene-shot-danger');
await page.waitForFunction(() => !!document.querySelector('.scene-shot-empty'));
check('Delete shot leaves the empty-state presentation', await page.evaluate(() => window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === 0));
await page.click('#scene-delete-btn');
await page.waitForFunction(() => !window.__godsEyeView.sceneDirector._project.scenes.some((item) => item.title === 'QA Scene'));
check('Deleting the final scene restores the built-in recipes', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length > 0 && document.querySelectorAll('#scene-select option').length > 0));
const teardown = await page.evaluate(async () => {
const director = window.__godsEyeView.sceneDirector;
const controls = director._controls;
const previousStatus = document.getElementById('scene-status').textContent;
const project = director._project;
let finish;
const gate = new Promise((resolve) => { finish = resolve; });
const pending = controls.run('import', { name: 'late.json', text: async () => { await gate; return '{"scenes":[]}'; } });
const stopping = director.destroy();
const stoppedSynchronously = controls.destroyed && controls.removers.length === 0 && controls.rowRemovers.length === 0;
document.getElementById('scene-new-btn').click();
controls.run('capture');
finish();
await pending;
await stopping;
await director.destroy();
return { stoppedSynchronously, sameProject: director._project === project, noLateStatus: document.getElementById('scene-status').textContent === previousStatus, rowsGone: !document.querySelector('.scene-shot-row') };
});
check('Disposal revokes fixed and row actions before waiting for scene work', teardown.stoppedSynchronously && teardown.rowsGone);
check('Late import completion cannot replace the disposed project or status', teardown.sameProject && teardown.noLateStatus);
check('Scene control interaction produces no uncaught browser errors', errors.length === 0);
} finally { await browser.close(); }
if (failures) process.exitCode = 1;
+4 -2
View File
@@ -1,3 +1,4 @@
import { SceneControls } from './ui/sceneControls.js';
import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js';
@@ -238,8 +239,9 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
css, css,
/body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/, /body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
); );
assert.match(sceneDirector, /this\._running = true;\s*document\.body\.classList\.add\('scene-playback-mode'\);/); assert.match(sceneDirector, /this\._running = true;\s*this\._controls\?\.setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*document\.body\.classList\.remove\('scene-playback-mode'\);/); assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._controls\?\.setPlaybackActive\(false\);/);
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner'); assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
assert.equal((html.match(/id="detection-toggle"/g) || []).length, 1, 'Detection control must have one stateful DOM owner'); assert.equal((html.match(/id="detection-toggle"/g) || []).length, 1, 'Detection control must have one stateful DOM owner');
assert.equal((html.match(/id="models3d-toggle"/g) || []).length, 1, '3D control must have one stateful DOM owner'); assert.equal((html.match(/id="models3d-toggle"/g) || []).length, 1, '3D control must have one stateful DOM owner');
+48 -219
View File
@@ -12,6 +12,7 @@
* State is persisted to localStorage and can be exported/imported as JSON. * State is persisted to localStorage and can be exported/imported as JSON.
*/ */
import { SceneControls } from '../ui/scenes.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { SCENE_RECIPES } from './recipes.js'; import { SCENE_RECIPES } from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
@@ -21,8 +22,6 @@ import {
decodeBloomIntensity, decodeBloomIntensity,
} from '../bloom.js'; } from '../bloom.js';
/** @constant {string} Key code used to abort a running scene */
const ESCAPE_KEY = 'Escape';
/** @constant {string} localStorage key for the serialized project */ /** @constant {string} localStorage key for the serialized project */
const STORAGE_KEY = 'godsEyeView.sceneProject.v2'; const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
/** @constant {number} Current schema version for project migration */ /** @constant {number} Current schema version for project migration */
@@ -32,15 +31,6 @@ const DEFAULT_SHOT_DURATION_SEC = 4;
/** @constant {number} Default hold/pause after a shot completes (seconds) */ /** @constant {number} Default hold/pause after a shot completes (seconds) */
const DEFAULT_HOLD_SEC = 0.9; const DEFAULT_HOLD_SEC = 0.9;
/**
* Clamp a numeric value to the [0, 1] range.
* @param {number} value
* @returns {number}
*/
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
/** /**
* Generate a short random identifier with the given prefix. * Generate a short random identifier with the given prefix.
* @param {string} prefix - e.g. 'shot', 'scene' * @param {string} prefix - e.g. 'shot', 'scene'
@@ -298,7 +288,6 @@ export class SceneDirector {
constructor(viewer, styleManager, dataManager) { constructor(viewer, styleManager, dataManager) {
this._destroyed = false; this._destroyed = false;
this._pendingWork = new Set(); this._pendingWork = new Set();
this._uiRemovers = [];
this.viewer = viewer; this.viewer = viewer;
this.styleManager = styleManager; this.styleManager = styleManager;
this.dataManager = dataManager; this.dataManager = dataManager;
@@ -321,39 +310,14 @@ export class SceneDirector {
this._lastRun = null; this._lastRun = null;
/** @type {string} JSON string of _lastRun for download */ /** @type {string} JSON string of _lastRun for download */
this._lastRunJson = ''; this._lastRunJson = '';
this._onKeyDown = this._onKeyDown.bind(this);
this._project = this._loadProject(); this._project = this._loadProject();
this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedSceneId = this._project.scenes[0]?.id || null;
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null; this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
// Cache DOM element references for the scene panel UI
this._scenePanel = document.getElementById('scene-panel');
this._sceneSelect = document.getElementById('scene-select');
this._sceneNewBtn = document.getElementById('scene-new-btn');
this._sceneDeleteBtn = document.getElementById('scene-delete-btn');
this._sceneCaptureBtn = document.getElementById('scene-capture-btn');
this._sceneUpdateShotBtn = document.getElementById('scene-update-shot-btn');
this._sceneShotList = document.getElementById('scene-shot-list');
this._sceneStartBtn = document.getElementById('scene-start-btn');
this._sceneStopBtn = document.getElementById('scene-stop-btn');
this._sceneNextBtn = document.getElementById('scene-next-btn');
this._sceneExportBtn = document.getElementById('scene-export-btn');
this._sceneImportBtn = document.getElementById('scene-import-btn');
this._sceneImportFile = document.getElementById('scene-import-file');
this._sceneDownloadBtn = document.getElementById('scene-download-btn');
this._sceneStatus = document.getElementById('scene-status');
this._sceneProgressFill = document.getElementById('scene-progress-fill');
this._sceneRuntime = document.getElementById('scene-runtime');
this._initUI(); this._initUI();
} }
_listen(target, type, listener) {
if (!target) return;
target.addEventListener(type, listener);
this._uiRemovers.push(() => target.removeEventListener(type, listener));
}
_trackWork(promise) { _trackWork(promise) {
this._pendingWork ||= new Set(); this._pendingWork ||= new Set();
@@ -367,18 +331,15 @@ export class SceneDirector {
destroy() { destroy() {
if (this._destroyPromise) return this._destroyPromise; if (this._destroyPromise) return this._destroyPromise;
this._destroyed = true; this._destroyed = true;
this._controls?.destroy();
this._destroyPromise = Promise.resolve().then(async () => { this._destroyPromise = Promise.resolve().then(async () => {
this.stopScene('Stopped'); this.stopScene('Stopped');
this._loadAbort?.abort(); this._loadAbort?.abort();
this._loadGeneration++; this._loadGeneration++;
this.viewer.camera.cancelFlight(); this.viewer.camera.cancelFlight();
for (const remove of this._uiRemovers || []) remove();
this._uiRemovers = [];
if (this._sceneShotList) this._sceneShotList.textContent = '';
clearTimeout(this._storageToastTimer); clearTimeout(this._storageToastTimer);
await Promise.allSettled(this._pendingWork || []); await Promise.allSettled(this._pendingWork || []);
clearInterval(this._progressTimer); clearInterval(this._progressTimer);
document.removeEventListener('keydown', this._onKeyDown);
}); });
return this._destroyPromise; return this._destroyPromise;
} }
@@ -434,79 +395,50 @@ export class SceneDirector {
* Exits silently if the scene-select element is missing (headless/test mode). * Exits silently if the scene-select element is missing (headless/test mode).
*/ */
_initUI() { _initUI() {
if (!this._sceneSelect) return; this._controls = new SceneControls({
read: () => ({
this._renderSceneSelect(); scenes: this._project.scenes,
this._renderShotList(); selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId,
this._listen(this._sceneSelect, 'change', () => { running: this._running,
this._selectedSceneId = this._sceneSelect.value; hasRun: !!this._lastRunJson,
const scene = this._getSelectedScene(); }),
this._selectedShotId = scene?.shots[0]?.id || null; actions: {
this._renderShotList(); selectScene: (id) => {
this._selectedSceneId = id;
this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
this._renderShotList();
},
selectShot: (id) => { this._selectedShotId = id; this._renderShotList(); },
renameShot: (sceneId, shotId, title) => {
const { shot } = this._getShot(sceneId, shotId);
if (!shot) return;
shot.title = title.trim() || shot.title;
this._saveProject();
this._renderShotList();
},
create: () => this._createScene(),
deleteScene: () => this._deleteSelectedScene(),
capture: () => this.captureShot(),
update: () => this.updateSelectedShot(),
start: (id) => this.startScene(id),
stop: (reason) => this.stopScene(reason),
next: () => this.runNextScene(),
export: () => this.exportProject(),
import: (file) => this.importProjectFile(file),
download: () => this.downloadLastRunMetadata(),
load: (sceneId, shotId) => this.loadShot(sceneId, shotId),
deleteShot: (sceneId, shotId) => this.deleteShot(sceneId, shotId),
},
}); });
this._listen(this._sceneNewBtn, 'click', () => this._createScene());
this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene());
this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot());
this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot());
this._listen(this._sceneStartBtn, 'click', () => {
this.startScene(this._selectedSceneId);
});
this._listen(this._sceneStopBtn, 'click', () => {
this.stopScene('Stopped');
});
this._listen(this._sceneNextBtn, 'click', () => {
this.runNextScene();
});
this._listen(this._sceneExportBtn, 'click', () => {
this.exportProject();
});
this._listen(this._sceneImportBtn, 'click', () => {
this._sceneImportFile?.click();
});
this._listen(this._sceneImportFile, 'change', async () => {
const file = this._sceneImportFile?.files?.[0];
if (!file) return;
await this.importProjectFile(file);
this._sceneImportFile.value = '';
});
this._listen(this._sceneDownloadBtn, 'click', () => {
this.downloadLastRunMetadata();
});
this._updateStatus('Ready');
this._setProgress(0);
this._setButtons(false);
} }
/** Rebuild the scene dropdown options and sync the selected value. */ /** Rebuild the scene dropdown options and sync the selected value. */
_renderSceneSelect() { _renderSceneSelect() {
if (!this._sceneSelect) return;
this._sceneSelect.innerHTML = '';
for (const scene of this._project.scenes) {
const option = document.createElement('option');
option.value = scene.id;
option.textContent = scene.title;
this._sceneSelect.appendChild(option);
}
// Reset selection if the previously selected scene no longer exists
if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) { if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) {
this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedSceneId = this._project.scenes[0]?.id || null;
} }
this._controls?.renderSceneSelect();
if (this._selectedSceneId) {
this._sceneSelect.value = this._selectedSceneId;
}
} }
/** /**
@@ -515,79 +447,11 @@ export class SceneDirector {
* LOAD/DEL action buttons. Supports click-to-select and double-click rename. * LOAD/DEL action buttons. Supports click-to-select and double-click rename.
*/ */
_renderShotList() { _renderShotList() {
if (!this._sceneShotList) return;
const scene = this._getSelectedScene(); const scene = this._getSelectedScene();
this._sceneShotList.innerHTML = ''; if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) {
if (!scene || scene.shots.length === 0) {
const empty = document.createElement('div');
empty.className = 'scene-shot-empty';
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
this._sceneShotList.appendChild(empty);
return;
}
// Auto-select first shot if current selection is stale
if (!scene.shots.some((shot) => shot.id === this._selectedShotId)) {
this._selectedShotId = scene.shots[0].id; this._selectedShotId = scene.shots[0].id;
} }
this._controls?.renderShotList();
for (const shot of scene.shots) {
const row = document.createElement('div');
row.className = 'scene-shot-row';
row.classList.toggle('active', shot.id === this._selectedShotId);
const top = document.createElement('div');
top.className = 'scene-shot-top';
const label = document.createElement('div');
label.className = 'scene-shot-label';
label.textContent = shot.title;
label.addEventListener('click', () => {
this._selectedShotId = shot.id;
this._renderShotList();
});
label.addEventListener('dblclick', () => {
const nextTitle = window.prompt('Shot title', shot.title);
if (!nextTitle) return;
shot.title = nextTitle.trim() || shot.title;
this._saveProject();
this._renderShotList();
});
const actions = document.createElement('div');
actions.className = 'scene-shot-actions';
const loadBtn = document.createElement('button');
loadBtn.className = 'scene-shot-btn';
loadBtn.textContent = 'LOAD';
loadBtn.addEventListener('click', () => {
this.loadShot(scene.id, shot.id);
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'scene-shot-btn scene-shot-danger';
deleteBtn.textContent = 'DEL';
deleteBtn.addEventListener('click', () => {
this.deleteShot(scene.id, shot.id);
});
actions.appendChild(loadBtn);
actions.appendChild(deleteBtn);
top.appendChild(label);
top.appendChild(actions);
const meta = document.createElement('div');
meta.className = 'scene-shot-meta';
const mode = shot.visual?.detection?.mode || 'OFF';
const style = shot.visual?.style || 'normal';
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
row.appendChild(top);
row.appendChild(meta);
this._sceneShotList.appendChild(row);
}
} }
/** /**
@@ -949,7 +813,7 @@ export class SceneDirector {
// Transition to running state // Transition to running state
this._running = true; this._running = true;
document.body.classList.add('scene-playback-mode'); this._controls?.setPlaybackActive(true);
this._setButtons(true); this._setButtons(true);
this._setProgress(0); this._setProgress(0);
@@ -984,7 +848,7 @@ export class SceneDirector {
this._startProgressTicker(estimatedDurationSec || 1); this._startProgressTicker(estimatedDurationSec || 1);
this._logEvent('scene_run_start', { count: queue.length }); this._logEvent('scene_run_start', { count: queue.length });
document.addEventListener('keydown', this._onKeyDown); this._controls?.setPlaybackKeyboardEnabled(true);
try { try {
// Main shot sequencing loop // Main shot sequencing loop
@@ -1340,14 +1204,14 @@ export class SceneDirector {
_finishRun() { _finishRun() {
clearInterval(this._progressTimer); clearInterval(this._progressTimer);
this._progressTimer = null; this._progressTimer = null;
document.removeEventListener('keydown', this._onKeyDown); this._controls?.setPlaybackKeyboardEnabled(false);
// Covers the error path too: a run that threw mid-shot must not leave a // 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. // layer transition running against a director that has stopped watching.
this._runAbort?.abort(); this._runAbort?.abort();
this._runAbort = null; this._runAbort = null;
this.styleManager.setRecordingMode(false); this.styleManager.setRecordingMode(false);
document.body.classList.remove('scene-playback-mode'); this._controls?.setPlaybackActive(false);
this._updateRuntime(''); this._updateRuntime('');
this._running = false; this._running = false;
@@ -1369,51 +1233,25 @@ export class SceneDirector {
* Editing controls are disabled during a run; stop is disabled when idle. * Editing controls are disabled during a run; stop is disabled when idle.
* @param {boolean} isRunning * @param {boolean} isRunning
*/ */
_setButtons(isRunning) { _setButtons(isRunning) { this._controls?.setButtons(isRunning); }
if (this._sceneStartBtn) this._sceneStartBtn.disabled = isRunning;
if (this._sceneNextBtn) this._sceneNextBtn.disabled = isRunning;
if (this._sceneSelect) this._sceneSelect.disabled = isRunning;
if (this._sceneNewBtn) this._sceneNewBtn.disabled = isRunning;
if (this._sceneDeleteBtn) this._sceneDeleteBtn.disabled = isRunning;
if (this._sceneCaptureBtn) this._sceneCaptureBtn.disabled = isRunning;
if (this._sceneUpdateShotBtn) this._sceneUpdateShotBtn.disabled = isRunning;
if (this._sceneExportBtn) this._sceneExportBtn.disabled = isRunning;
if (this._sceneImportBtn) this._sceneImportBtn.disabled = isRunning;
if (this._sceneStopBtn) this._sceneStopBtn.disabled = !isRunning;
if (this._sceneDownloadBtn) this._sceneDownloadBtn.disabled = !this._lastRunJson;
if (this._scenePanel) this._scenePanel.classList.toggle('running', isRunning);
}
/** /**
* Update the progress bar fill width and label. * Update the progress bar fill width and label.
* @param {number} progress - Value in [0, 1] * @param {number} progress - Value in [0, 1]
*/ */
_setProgress(progress) { _setProgress(progress) { this._controls?.setProgress(progress); }
if (!this._sceneProgressFill) return;
const pct = Math.round(clamp01(progress) * 100);
this._sceneProgressFill.style.width = `${pct}%`;
this._sceneProgressFill.textContent = `${pct}%`;
}
/** /**
* Set the status line text in the scene panel. * Set the status line text in the scene panel.
* @param {string} text * @param {string} text
*/ */
_updateStatus(text) { _updateStatus(text) { this._controls?.updateStatus(text); }
if (this._sceneStatus) this._sceneStatus.textContent = text;
}
/** /**
* Set the runtime label (scene/shot name) and toggle its active class. * Set the runtime label (scene/shot name) and toggle its active class.
* @param {string} text - Empty string hides the label * @param {string} text - Empty string hides the label
*/ */
_updateRuntime(text) { _updateRuntime(text) { this._controls?.updateRuntime(text); }
if (!this._sceneRuntime) return;
this._sceneRuntime.textContent = text;
this._sceneRuntime.classList.toggle('active', !!text);
}
/** /**
* Append a timestamped telemetry event to the active run log. * Append a timestamped telemetry event to the active run log.
@@ -1429,13 +1267,4 @@ export class SceneDirector {
}); });
} }
/**
* Global keydown handler registered during a run. Escape cancels the run.
* @param {KeyboardEvent} event
*/
_onKeyDown(event) {
if (event.key === ESCAPE_KEY && this._running) {
this.stopScene('Stopped (Esc)');
}
}
} }
+116
View File
@@ -0,0 +1,116 @@
/** Own Scene panel listeners and presentation through project reads and actions. */
import { sceneElements, renderSceneOptions, renderSceneShots, presentSceneButtons, presentSceneProgress, presentSceneRuntime } from './scenePresentation.js';
export class SceneControls {
constructor({ read, actions, elements = sceneElements() }) {
this.read = read;
this.actions = actions;
this.elements = elements;
this.destroyed = false;
this.actionGeneration = 0;
this.removers = [];
this.rowRemovers = [];
this.playbackKeyRemover = null;
if (!elements.select) return;
this.renderSceneSelect();
this.renderShotList();
this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value));
for (const [element, action] of Object.entries({ new: 'create', delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', export: 'export', download: 'download' })) {
this.listen(elements[element], 'click', () => this.run(action));
}
this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId));
this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped'));
this.listen(elements.import, 'click', () => elements.file?.click());
this.listen(elements.file, 'change', async () => {
const file = elements.file?.files?.[0];
if (!file) return;
await this.run('import', file);
if (!this.destroyed) elements.file.value = '';
});
this.updateStatus('Ready');
this.setProgress(0);
this.setButtons(false);
}
listen(target, type, callback, removers = this.removers) {
if (!target) return;
const listener = (event) => { if (!this.destroyed) return callback(event); };
target.addEventListener(type, listener);
removers.push(() => target.removeEventListener(type, listener));
}
run(action, ...args) {
if (this.destroyed) return;
const generation = ++this.actionGeneration;
const failed = () => {
if (!this.destroyed && generation === this.actionGeneration) this.updateStatus('Scene action failed');
};
try {
const result = this.actions[action](...args);
return result?.then ? Promise.resolve(result).catch(failed) : result;
} catch {
failed();
}
}
renderSceneSelect() {
if (!this.destroyed) renderSceneOptions(this.elements.select, this.read());
}
renderShotList() {
if (this.destroyed) return;
for (const remove of this.rowRemovers.splice(0)) remove();
renderSceneShots(this.elements.shots, this.read(), {
listen: (element, type, callback) => this.listen(element, type, callback, this.rowRemovers),
select: (id) => this.run('selectShot', id),
rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title),
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId),
});
}
setButtons(running) {
if (!this.destroyed) presentSceneButtons(this.elements, running, this.read().hasRun);
}
setProgress(progress) {
if (!this.destroyed) presentSceneProgress(this.elements.progress, progress);
}
updateStatus(text) {
if (!this.destroyed && this.elements.status) this.elements.status.textContent = text;
}
updateRuntime(text) {
if (!this.destroyed) presentSceneRuntime(this.elements.runtime, text);
}
setPlaybackActive(active) {
if (this.destroyed && active) return;
document.body.classList.toggle('scene-playback-mode', active);
}
setPlaybackKeyboardEnabled(enabled) {
if (!enabled) {
this.playbackKeyRemover?.();
this.playbackKeyRemover = null;
} else if (!this.destroyed && !this.playbackKeyRemover) {
const onKeyDown = (event) => {
if (!this.destroyed && event.key === 'Escape' && this.read().running) this.run('stop', 'Stopped (Esc)');
};
document.addEventListener('keydown', onKeyDown);
this.playbackKeyRemover = () => document.removeEventListener('keydown', onKeyDown);
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.actionGeneration++;
this.setPlaybackKeyboardEnabled(false);
for (const remove of [...this.removers.splice(0), ...this.rowRemovers.splice(0)]) remove();
if (this.elements.shots) this.elements.shots.textContent = '';
presentSceneRuntime(this.elements.runtime, '');
this.setPlaybackActive(false);
}
}
+145
View File
@@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SceneControls } from './sceneControls.js';
function fixture() {
class Element extends EventTarget {
constructor() {
super();
this.children = [];
this.style = {};
this.value = '';
this._text = '';
const classes = new Set();
this.classList = {
toggle(name, active) { if (active) classes.add(name); else classes.delete(name); },
contains: (name) => classes.has(name),
};
}
appendChild(element) { this.children.push(element); }
set textContent(value) { this._text = value; this.children = []; }
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
click() { this.dispatchEvent(new Event('click')); }
}
const saved = { document: globalThis.document, window: globalThis.window };
const document = Object.assign(new EventTarget(), { createElement: () => new Element(), body: new Element() });
globalThis.document = document;
globalThis.window = { prompt: () => 'Renamed' };
const elements = Object.fromEntries(['panel','select','new','delete','capture','update','shots','start','stop','next','export','import','file','download','status','progress','runtime'].map((name) => [name, new Element()]));
const state = {
scenes: [{ id: 'scene-a', title: '<Scene A>', shots: [{ id: 'shot-a', title: '<Shot A>', durationSec: 4, holdSec: 0.9, visual: { style: 'normal' } }] }],
selectedSceneId: 'scene-a', selectedShotId: 'shot-a', running: false, hasRun: false,
};
const calls = [];
const actions = Object.fromEntries(['selectScene','selectShot','renameShot','create','deleteScene','capture','update','start','stop','next','export','import','download','load','deleteShot'].map((name) => [name, (...args) => { calls.push([name, ...args]); }]));
const owner = new SceneControls({ read: () => state, actions, elements });
return { owner, state, actions, elements, calls, document, restore() { owner.destroy(); Object.assign(globalThis, saved); } };
}
test('Scene controls render the supplied selection and running/download states', () => {
const f = fixture();
try {
assert.equal(f.elements.select.children[0].textContent, '<Scene A>');
assert.equal(f.elements.select.value, 'scene-a');
assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, '<Shot A>');
assert.equal(f.elements.stop.disabled, true);
assert.equal(f.elements.download.disabled, true);
f.state.hasRun = true;
f.owner.setButtons(true);
assert.equal(f.elements.start.disabled, true);
assert.equal(f.elements.capture.disabled, true);
assert.equal(f.elements.stop.disabled, false);
assert.equal(f.elements.download.disabled, false);
assert.equal(f.elements.panel.classList.contains('running'), true);
f.owner.setProgress(2);
assert.equal(f.elements.progress.style.width, '100%');
f.owner.setProgress(-1);
assert.equal(f.elements.progress.textContent, '0%');
} finally { f.restore(); }
});
test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => {
const f = fixture();
try {
const row = f.elements.shots.children[0];
const label = row.children[0].children[0];
const load = row.children[0].children[1].children[0];
f.elements.capture.click();
f.elements.start.click();
label.click();
label.dispatchEvent(new Event('dblclick'));
load.click();
assert.deepEqual(f.calls, [['capture'], ['start','scene-a'], ['selectShot','shot-a'], ['renameShot','scene-a','shot-a','Renamed'], ['load','scene-a','shot-a']]);
f.owner.renderShotList();
label.click();
load.click();
assert.equal(f.calls.length, 5);
f.elements.shots.children[0].children[0].children[1].children[0].click();
assert.equal(f.calls.length, 6);
} finally { f.restore(); }
});
test('Scene Escape and recording presentation follow playback and release on destruction', () => {
const f = fixture();
const escape = () => f.document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' }));
try {
escape();
assert.deepEqual(f.calls, []);
f.state.running = true;
f.owner.setPlaybackActive(true);
f.owner.setPlaybackKeyboardEnabled(true);
f.owner.updateRuntime('Scene A / Shot A');
escape();
assert.deepEqual(f.calls, [['stop', 'Stopped (Esc)']]);
f.owner.destroy();
f.owner.destroy();
f.owner.setPlaybackKeyboardEnabled(true);
escape();
f.elements.capture.click();
f.owner.run('capture');
assert.equal(f.calls.length, 1);
assert.equal(f.document.body.classList.contains('scene-playback-mode'), false);
assert.equal(f.elements.runtime.textContent, '');
assert.equal(f.owner.removers.length, 0);
assert.equal(f.owner.rowRemovers.length, 0);
} finally { f.restore(); }
});
test('a file import finishing after disposal cannot clear the retained file control', async () => {
const f = fixture();
try {
let finish;
f.actions.import = () => new Promise((resolve) => { finish = resolve; });
f.elements.file.files = [{ name: 'project.json' }];
f.elements.file.value = 'project.json';
f.elements.file.dispatchEvent(new Event('change'));
f.owner.destroy();
finish();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.elements.file.value, 'project.json');
assert.equal(f.elements.status.textContent, 'Ready');
} finally { f.restore(); }
});
test('Scene action failures are visible only while that action still owns presentation', async () => {
const f = fixture();
try {
let reject;
f.actions.load = () => new Promise((_resolve, fail) => { reject = fail; });
let pending = f.owner.run('load', 'scene-a', 'shot-a');
reject(new Error('unavailable'));
await pending;
assert.equal(f.elements.status.textContent, 'Scene action failed');
pending = f.owner.run('load', 'scene-a', 'shot-a');
f.owner.run('selectShot', 'shot-a');
f.owner.updateStatus('Current selection');
reject(new Error('obsolete'));
await pending;
assert.equal(f.elements.status.textContent, 'Current selection');
pending = f.owner.run('load', 'scene-a', 'shot-a');
f.owner.destroy();
reject(new Error('disposed'));
await pending;
assert.equal(f.elements.status.textContent, 'Current selection');
} finally { f.restore(); }
});
+96
View File
@@ -0,0 +1,96 @@
/** Scene panel elements and text-only project presentation. */
export function sceneElements(root = document) {
const ids = {
panel: 'scene-panel', select: 'scene-select', new: 'scene-new-btn',
delete: 'scene-delete-btn', capture: 'scene-capture-btn', update: 'scene-update-shot-btn',
shots: 'scene-shot-list', start: 'scene-start-btn', stop: 'scene-stop-btn',
next: 'scene-next-btn', export: 'scene-export-btn', import: 'scene-import-btn',
file: 'scene-import-file', download: 'scene-download-btn', status: 'scene-status',
progress: 'scene-progress-fill', runtime: 'scene-runtime',
};
return Object.fromEntries(Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)]));
}
export function renderSceneOptions(element, { scenes, selectedSceneId }) {
if (!element) return;
element.textContent = '';
for (const scene of scenes) {
const option = document.createElement('option');
option.value = scene.id;
option.textContent = scene.title;
element.appendChild(option);
}
if (selectedSceneId) element.value = selectedSceneId;
}
export function renderSceneShots(element, state, { listen, select, rename, load, remove }) {
if (!element) return;
const scene = state.scenes.find((item) => item.id === state.selectedSceneId);
element.textContent = '';
if (!scene || scene.shots.length === 0) {
const empty = document.createElement('div');
empty.className = 'scene-shot-empty';
empty.textContent = 'No shots yet. Use CAPTURE SHOT to save current look.';
element.appendChild(empty);
return;
}
for (const shot of scene.shots) {
const row = document.createElement('div');
row.className = 'scene-shot-row';
row.classList.toggle('active', shot.id === state.selectedShotId);
const top = document.createElement('div');
top.className = 'scene-shot-top';
const label = document.createElement('div');
label.className = 'scene-shot-label';
label.textContent = shot.title;
listen(label, 'click', () => select(shot.id));
listen(label, 'dblclick', () => {
const title = window.prompt('Shot title', shot.title);
if (title) rename(scene.id, shot.id, title);
});
const actions = document.createElement('div');
actions.className = 'scene-shot-actions';
const loadButton = document.createElement('button');
loadButton.className = 'scene-shot-btn';
loadButton.textContent = 'LOAD';
listen(loadButton, 'click', () => load(scene.id, shot.id));
const deleteButton = document.createElement('button');
deleteButton.className = 'scene-shot-btn scene-shot-danger';
deleteButton.textContent = 'DEL';
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton);
actions.appendChild(deleteButton);
top.appendChild(label);
top.appendChild(actions);
const meta = document.createElement('div');
meta.className = 'scene-shot-meta';
const mode = shot.visual?.detection?.mode || 'OFF';
const style = shot.visual?.style || 'normal';
meta.textContent = `${style.toUpperCase()} · ${mode} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
row.appendChild(top);
row.appendChild(meta);
element.appendChild(row);
}
}
export function presentSceneButtons(elements, running, hasRun) {
for (const name of ['start', 'next', 'select', 'new', 'delete', 'capture', 'update', 'export', 'import']) {
if (elements[name]) elements[name].disabled = running;
}
if (elements.stop) elements.stop.disabled = !running;
if (elements.download) elements.download.disabled = !hasRun;
elements.panel?.classList.toggle('running', running);
}
export function presentSceneProgress(element, progress) {
if (!element) return;
const percentage = Math.round(Math.max(0, Math.min(1, progress)) * 100);
element.style.width = `${percentage}%`;
element.textContent = `${percentage}%`;
}
export function presentSceneRuntime(element, text) {
if (!element) return;
element.textContent = text;
element.classList.toggle('active', !!text);
}
+1
View File
@@ -0,0 +1 @@
export { SceneControls } from './sceneControls.js';