mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #407 from bilawalsidhu/refactor/scene-controls
refactor: separate Scene controls from project and playback operations
This commit is contained in:
14 files changed
+1116
-234
No files matched your search
@@ -13,6 +13,9 @@ 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.
|
||||
- Preserve shot-label identity on selection so double-click rename can complete.
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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 prompts, 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.
|
||||
@@ -1,5 +1,14 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Scene control ownership
|
||||
|
||||
Scene controls own creation/deletion prompts, 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.
|
||||
Shot selection updates the highlight without replacing the row, preserving
|
||||
native double-click rename. 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
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -208,5 +208,10 @@
|
||||
"src/ui/cockpitTrackingController.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js"
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs"
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/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', { count: 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.getControlState().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.getControlState().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;
|
||||
@@ -3203,10 +3203,25 @@ async function main() {
|
||||
await fl.update(v);
|
||||
await window.__dfSettle(600);
|
||||
}
|
||||
const bb1 = window.__dfFindBB('aaa097');
|
||||
const d1 = bb1 ? window.__dfCarto(bb1.position) : null;
|
||||
// The floor owner intentionally retains the previous cell near an
|
||||
// edge. Sample inside a cell, beyond that hysteresis band, so the raw
|
||||
// coordinate's floor is unambiguously the floor the sprite must use.
|
||||
// Wait for geometry, not a passing height; a missing clamp still fails.
|
||||
const interiorLimit = 0.0005 - gf.CELL_HYSTERESIS_DEG - 0.00002;
|
||||
const sampleDeadline = Date.now() + 8000;
|
||||
let d1 = null;
|
||||
let sampleInterior = false;
|
||||
do {
|
||||
await window.__dfSettle(250);
|
||||
const bb1 = window.__dfFindBB('aaa097');
|
||||
d1 = bb1 ? window.__dfCarto(bb1.position) : null;
|
||||
sampleInterior = !!d1
|
||||
&& Math.abs(d1.lat - cell(d1.lat)) < interiorLimit
|
||||
&& Math.abs(d1.lon - cell(d1.lon)) < interiorLimit;
|
||||
} while (!sampleInterior && Date.now() < sampleDeadline);
|
||||
return {
|
||||
startCold,
|
||||
sampleInterior,
|
||||
displayCell,
|
||||
fixCell,
|
||||
sameCellAsFix: displayCell.lat === fixCell.lat && displayCell.lon === fixCell.lon,
|
||||
@@ -3249,6 +3264,10 @@ async function main() {
|
||||
dfCorridor.controlFloor == null,
|
||||
`control cell floor = ${dfCorridor.controlFloor}`);
|
||||
|
||||
record('display-floor/corridor: the height sample clears cell-boundary hysteresis',
|
||||
dfCorridor.sampleInterior === true,
|
||||
'the sampled coordinate lies inside one unambiguous floor cell');
|
||||
|
||||
record('display-floor/corridor: the sprite rides the corridor-warmed floor',
|
||||
Number.isFinite(dfCorridor.spriteH) && Number.isFinite(dfCorridor.spriteFloor)
|
||||
&& dfCorridor.spriteH >= dfCorridor.spriteFloor + DISPLAY_FLOOR_LIFT_M - 0.5,
|
||||
|
||||
@@ -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');
|
||||
|
||||
+50
-228
@@ -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; },
|
||||
renameShot: (sceneId, shotId, title) => {
|
||||
const { shot } = this._getShot(sceneId, shotId);
|
||||
if (!shot) return;
|
||||
shot.title = title.trim() || shot.title;
|
||||
this._saveProject();
|
||||
this._renderShotList();
|
||||
},
|
||||
create: (name) => this._createScene(name),
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,9 +474,8 @@ export class SceneDirector {
|
||||
return { scene, shot };
|
||||
}
|
||||
|
||||
/** Prompt the user for a name and append a new empty scene to the project. */
|
||||
_createScene() {
|
||||
const sceneName = window.prompt('New scene name', `Scene ${this._project.scenes.length + 1}`);
|
||||
/** Append a named empty scene after the controls accept the creation prompt. */
|
||||
_createScene(sceneName) {
|
||||
if (!sceneName) return;
|
||||
|
||||
const scene = {
|
||||
@@ -634,9 +497,6 @@ export class SceneDirector {
|
||||
const scene = this._getSelectedScene();
|
||||
if (!scene) return;
|
||||
|
||||
const ok = window.confirm(`Delete scene "${scene.title}" and all shots?`);
|
||||
if (!ok) return;
|
||||
|
||||
this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id);
|
||||
// Restore default recipes if the user deleted all scenes
|
||||
if (!this._project.scenes.length) {
|
||||
@@ -732,9 +592,6 @@ export class SceneDirector {
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
if (!scene || !shot) return;
|
||||
|
||||
const ok = window.confirm(`Delete shot "${shot.title}"?`);
|
||||
if (!ok) return;
|
||||
|
||||
scene.shots = scene.shots.filter((item) => item.id !== shot.id);
|
||||
this._selectedShotId = scene.shots[0]?.id || null;
|
||||
this._saveProject();
|
||||
@@ -949,7 +806,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 +841,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 +1197,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 +1226,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 +1260,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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/** Own Scene panel listeners and presentation through project reads and actions. */
|
||||
import {
|
||||
sceneElements,
|
||||
renderSceneOptions,
|
||||
renderSceneShots,
|
||||
presentSceneSelection,
|
||||
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({
|
||||
capture: 'capture',
|
||||
update: 'update',
|
||||
next: 'next',
|
||||
export: 'export',
|
||||
download: 'download',
|
||||
})) {
|
||||
this.listen(elements[element], 'click', () => this.run(action));
|
||||
}
|
||||
this.listen(elements.new, 'click', () => this.createScene());
|
||||
this.listen(elements.delete, 'click', () => this.deleteSelectedScene());
|
||||
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());
|
||||
}
|
||||
|
||||
createScene() {
|
||||
if (this.destroyed) return;
|
||||
const name = window.prompt(
|
||||
'New scene name',
|
||||
`Scene ${this.read().scenes.length + 1}`,
|
||||
);
|
||||
if (name) this.run('create', name);
|
||||
}
|
||||
|
||||
deleteSelectedScene() {
|
||||
if (this.destroyed) return;
|
||||
const state = this.read();
|
||||
const scene = state.scenes.find(
|
||||
(item) => item.id === state.selectedSceneId,
|
||||
);
|
||||
if (scene && window.confirm(`Delete scene "${scene.title}" and all shots?`))
|
||||
this.run('deleteScene');
|
||||
}
|
||||
|
||||
deleteShot(sceneId, shotId) {
|
||||
if (this.destroyed) return;
|
||||
const scene = this.read().scenes.find((item) => item.id === sceneId);
|
||||
const shot = scene?.shots.find((item) => item.id === shotId);
|
||||
if (shot && window.confirm(`Delete shot "${shot.title}"?`))
|
||||
this.run('deleteShot', sceneId, shotId);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!this.destroyed)
|
||||
presentSceneSelection(
|
||||
this.elements.shots,
|
||||
this.read().selectedShotId,
|
||||
);
|
||||
},
|
||||
rename: (sceneId, shotId, title) =>
|
||||
this.run('renameShot', sceneId, shotId, title),
|
||||
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
|
||||
remove: (sceneId, shotId) => this.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SceneControls } from './sceneControls.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
|
||||
function fixture() {
|
||||
class Element extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.children = [];
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test('creation and deletion prompts precede their model actions and remain inert after disposal', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
let prompts = 0;
|
||||
window.prompt = () => {
|
||||
prompts++;
|
||||
return null;
|
||||
};
|
||||
window.confirm = () => {
|
||||
prompts++;
|
||||
return false;
|
||||
};
|
||||
f.elements.new.click();
|
||||
f.elements.delete.click();
|
||||
f.owner.deleteShot('scene-a', 'shot-a');
|
||||
assert.deepEqual(f.calls, []);
|
||||
window.prompt = () => {
|
||||
prompts++;
|
||||
return 'New title';
|
||||
};
|
||||
window.confirm = () => {
|
||||
prompts++;
|
||||
return true;
|
||||
};
|
||||
f.elements.new.click();
|
||||
f.elements.delete.click();
|
||||
f.owner.deleteShot('scene-a', 'shot-a');
|
||||
assert.deepEqual(f.calls, [
|
||||
['create', 'New title'],
|
||||
['deleteScene'],
|
||||
['deleteShot', 'scene-a', 'shot-a'],
|
||||
]);
|
||||
f.owner.destroy();
|
||||
f.owner.createScene();
|
||||
f.owner.deleteSelectedScene();
|
||||
f.owner.deleteShot('scene-a', 'shot-a');
|
||||
assert.equal(prompts, 6);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the real director preserves a selected shot label for the following double-click', async () => {
|
||||
const f = fixture();
|
||||
const previousStorage = globalThis.localStorage;
|
||||
let director;
|
||||
try {
|
||||
f.owner.destroy();
|
||||
const ids = {
|
||||
'scene-panel': 'panel',
|
||||
'scene-select': 'select',
|
||||
'scene-new-btn': 'new',
|
||||
'scene-delete-btn': 'delete',
|
||||
'scene-capture-btn': 'capture',
|
||||
'scene-update-shot-btn': 'update',
|
||||
'scene-shot-list': 'shots',
|
||||
'scene-start-btn': 'start',
|
||||
'scene-stop-btn': 'stop',
|
||||
'scene-next-btn': 'next',
|
||||
'scene-export-btn': 'export',
|
||||
'scene-import-btn': 'import',
|
||||
'scene-import-file': 'file',
|
||||
'scene-download-btn': 'download',
|
||||
'scene-status': 'status',
|
||||
'scene-progress-fill': 'progress',
|
||||
'scene-runtime': 'runtime',
|
||||
};
|
||||
f.document.getElementById = (id) => f.elements[ids[id]] || null;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({ version: 3, scenes: f.state.scenes }),
|
||||
setItem() {},
|
||||
};
|
||||
director = new SceneDirector({ camera: { cancelFlight() {} } }, {}, {});
|
||||
const label = f.elements.shots.children[0].children[0].children[0];
|
||||
label.click();
|
||||
assert.equal(f.elements.shots.children[0].children[0].children[0], label);
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
assert.equal(director._getSelectedScene().shots[0].title, 'Renamed');
|
||||
} finally {
|
||||
await director?.destroy();
|
||||
globalThis.localStorage = previousStorage;
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/** 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.dataset.sceneShotId = shot.id;
|
||||
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 presentSceneSelection(element, selectedShotId) {
|
||||
for (const row of element?.children || []) {
|
||||
row.classList.toggle('active', row.dataset.sceneShotId === selectedShotId);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SceneControls } from './sceneControls.js';
|
||||
Reference in new issue
Block a user