diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bae06ad2..b3602f2b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [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.
- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown.
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index 2c89f7ff4..cce4e8f20 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -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.
Disposal releases subscriptions and pending work; portal moves preserve the
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.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index a383d4012..5e267ab59 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,13 @@
# 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 presentation is separated from its camera/controller behavior. Existing
diff --git a/package.json b/package.json
index b8d871123..d0bead40e 100644
--- a/package.json
+++ b/package.json
@@ -137,6 +137,7 @@
"./ui/cockpit": "./src/ui/cockpit.js",
"./ui/cockpit/math": "./src/cockpitMath.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"
}
}
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 43373f43e..16b60d839 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -414,5 +414,14 @@
"src/ui/panelRailGeometry.js"
],
"external": ["cesium"]
+ },
+ "scene-controls": {
+ "exports": ["./ui/scenes"],
+ "modules": [
+ "src/ui/scenes.js",
+ "src/ui/sceneControls.js",
+ "src/ui/scenePresentation.js"
+ ],
+ "external": []
}
}
diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs
new file mode 100644
index 000000000..af124f04e
--- /dev/null
+++ b/scripts/qa-scene-controls.mjs
@@ -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 = 'QA shot';
+ await page.click('.scene-shot-label', { clickCount: 2 });
+ await page.waitForFunction(() => document.querySelector('.scene-shot-label')?.textContent === 'QA shot');
+ 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 === 'QA shot'))));
+ 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('QA shot')));
+ 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;
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index adfea26bc..e67fae046 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -1,3 +1,4 @@
+import { SceneControls } from './ui/sceneControls.js';
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.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,
/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, /styleManager\.setRecordingMode\(false\);\s*document\.body\.classList\.remove\('scene-playback-mode'\);/);
+ assert.match(sceneDirector, /this\._running = true;\s*this\._controls\?\.setPlaybackActive\(true\);/);
+ 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="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');
diff --git a/src/scenes/director.js b/src/scenes/director.js
index 4109c307f..9160fcc4d 100644
--- a/src/scenes/director.js
+++ b/src/scenes/director.js
@@ -12,6 +12,7 @@
* State is persisted to localStorage and can be exported/imported as JSON.
*/
+import { SceneControls } from '../ui/scenes.js';
import * as Cesium from 'cesium';
import { SCENE_RECIPES } from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
@@ -21,8 +22,6 @@ import {
decodeBloomIntensity,
} 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 */
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
/** @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) */
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.
* @param {string} prefix - e.g. 'shot', 'scene'
@@ -298,7 +288,6 @@ export class SceneDirector {
constructor(viewer, styleManager, dataManager) {
this._destroyed = false;
this._pendingWork = new Set();
- this._uiRemovers = [];
this.viewer = viewer;
this.styleManager = styleManager;
this.dataManager = dataManager;
@@ -321,39 +310,14 @@ export class SceneDirector {
this._lastRun = null;
/** @type {string} JSON string of _lastRun for download */
this._lastRunJson = '';
- this._onKeyDown = this._onKeyDown.bind(this);
this._project = this._loadProject();
this._selectedSceneId = this._project.scenes[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();
}
- _listen(target, type, listener) {
- if (!target) return;
- target.addEventListener(type, listener);
- this._uiRemovers.push(() => target.removeEventListener(type, listener));
- }
_trackWork(promise) {
this._pendingWork ||= new Set();
@@ -367,18 +331,15 @@ export class SceneDirector {
destroy() {
if (this._destroyPromise) return this._destroyPromise;
this._destroyed = true;
+ this._controls?.destroy();
this._destroyPromise = Promise.resolve().then(async () => {
this.stopScene('Stopped');
this._loadAbort?.abort();
this._loadGeneration++;
this.viewer.camera.cancelFlight();
- for (const remove of this._uiRemovers || []) remove();
- this._uiRemovers = [];
- if (this._sceneShotList) this._sceneShotList.textContent = '';
clearTimeout(this._storageToastTimer);
await Promise.allSettled(this._pendingWork || []);
clearInterval(this._progressTimer);
- document.removeEventListener('keydown', this._onKeyDown);
});
return this._destroyPromise;
}
@@ -434,79 +395,50 @@ export class SceneDirector {
* Exits silently if the scene-select element is missing (headless/test mode).
*/
_initUI() {
- if (!this._sceneSelect) return;
-
- this._renderSceneSelect();
- this._renderShotList();
-
- this._listen(this._sceneSelect, 'change', () => {
- this._selectedSceneId = this._sceneSelect.value;
- const scene = this._getSelectedScene();
- this._selectedShotId = scene?.shots[0]?.id || null;
- this._renderShotList();
+ this._controls = new SceneControls({
+ read: () => ({
+ scenes: this._project.scenes,
+ selectedSceneId: this._selectedSceneId,
+ selectedShotId: this._selectedShotId,
+ running: this._running,
+ hasRun: !!this._lastRunJson,
+ }),
+ actions: {
+ 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. */
_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)) {
this._selectedSceneId = this._project.scenes[0]?.id || null;
}
-
- if (this._selectedSceneId) {
- this._sceneSelect.value = this._selectedSceneId;
- }
+ this._controls?.renderSceneSelect();
}
/**
@@ -515,79 +447,11 @@ export class SceneDirector {
* LOAD/DEL action buttons. Supports click-to-select and double-click rename.
*/
_renderShotList() {
- if (!this._sceneShotList) return;
-
const scene = this._getSelectedScene();
- this._sceneShotList.innerHTML = '';
-
- 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)) {
+ if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) {
this._selectedShotId = scene.shots[0].id;
}
-
- 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);
- }
+ this._controls?.renderShotList();
}
/**
@@ -949,7 +813,7 @@ export class SceneDirector {
// Transition to running state
this._running = true;
- document.body.classList.add('scene-playback-mode');
+ this._controls?.setPlaybackActive(true);
this._setButtons(true);
this._setProgress(0);
@@ -984,7 +848,7 @@ export class SceneDirector {
this._startProgressTicker(estimatedDurationSec || 1);
this._logEvent('scene_run_start', { count: queue.length });
- document.addEventListener('keydown', this._onKeyDown);
+ this._controls?.setPlaybackKeyboardEnabled(true);
try {
// Main shot sequencing loop
@@ -1340,14 +1204,14 @@ export class SceneDirector {
_finishRun() {
clearInterval(this._progressTimer);
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
// layer transition running against a director that has stopped watching.
this._runAbort?.abort();
this._runAbort = null;
this.styleManager.setRecordingMode(false);
- document.body.classList.remove('scene-playback-mode');
+ this._controls?.setPlaybackActive(false);
this._updateRuntime('');
this._running = false;
@@ -1369,51 +1233,25 @@ export class SceneDirector {
* Editing controls are disabled during a run; stop is disabled when idle.
* @param {boolean} isRunning
*/
- _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);
- }
+ _setButtons(isRunning) { this._controls?.setButtons(isRunning); }
/**
* Update the progress bar fill width and label.
* @param {number} progress - Value in [0, 1]
*/
- _setProgress(progress) {
- if (!this._sceneProgressFill) return;
- const pct = Math.round(clamp01(progress) * 100);
- this._sceneProgressFill.style.width = `${pct}%`;
- this._sceneProgressFill.textContent = `${pct}%`;
- }
+ _setProgress(progress) { this._controls?.setProgress(progress); }
/**
* Set the status line text in the scene panel.
* @param {string} text
*/
- _updateStatus(text) {
- if (this._sceneStatus) this._sceneStatus.textContent = text;
- }
+ _updateStatus(text) { this._controls?.updateStatus(text); }
/**
* Set the runtime label (scene/shot name) and toggle its active class.
* @param {string} text - Empty string hides the label
*/
- _updateRuntime(text) {
- if (!this._sceneRuntime) return;
- this._sceneRuntime.textContent = text;
- this._sceneRuntime.classList.toggle('active', !!text);
- }
+ _updateRuntime(text) { this._controls?.updateRuntime(text); }
/**
* 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)');
- }
- }
}
diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js
new file mode 100644
index 000000000..26e032258
--- /dev/null
+++ b/src/ui/sceneControls.js
@@ -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);
+ }
+}
diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs
new file mode 100644
index 000000000..512ffb8a6
--- /dev/null
+++ b/src/ui/sceneControls.test.mjs
@@ -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: '', shots: [{ id: 'shot-a', title: '', 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, '');
+ assert.equal(f.elements.select.value, 'scene-a');
+ assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, '');
+ 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(); }
+});
diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js
new file mode 100644
index 000000000..a4a449a08
--- /dev/null
+++ b/src/ui/scenePresentation.js
@@ -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);
+}
diff --git a/src/ui/scenes.js b/src/ui/scenes.js
new file mode 100644
index 000000000..57ac18fb4
--- /dev/null
+++ b/src/ui/scenes.js
@@ -0,0 +1 @@
+export { SceneControls } from './sceneControls.js';