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';