Merge branch 'main' into fix/director-scene-results-971

# Conflicts:
#	src/scenes/director.js
This commit is contained in:
Sameh Khamis committed 2026-10-16 15:30:05 -07:00
commit 75b1c66006
700 files changed
+66498 -6002

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { renderPackGeometry } from './geometry.js';
import { t } from '../../i18n/index.js';
function caption(viewer, pack) {
const card = document.createElement('section');
@@ -18,7 +19,7 @@ function caption(viewer, pack) {
card.append(title);
if (pack.attribution.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.textContent = t('Source');
link.href = pack.attribution.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
+124 -42
View File
@@ -23,9 +23,12 @@ import * as Cesium from 'cesium';
import {
SCENE_APPEND_RECIPES,
SCENE_RECIPES,
displaySceneTitle,
displayShotTitle,
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { t, tn, msg } from '../i18n/index.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createSceneInteractions } from './interactions.js';
import { createSceneSharing } from './sharing.js';
@@ -60,6 +63,7 @@ import {
stringifySceneDocument,
SceneDocumentError,
} from '../director/document.js';
import { sceneSaveFailureReason } from './sceneSaveError.js';
/** @constant {string} localStorage key for the serialized project */
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
@@ -122,7 +126,7 @@ export class SceneDirector {
)
return;
if (this._usesAuthoredCamera && !this._claimingCamera)
this.stopScene('Camera ownership changed');
this.stopScene(msg('Camera ownership changed'));
};
this._cameraHandoffUnsubscribe =
styleManager.subscribeCameraHandoff?.(yieldCamera);
@@ -171,8 +175,10 @@ export class SceneDirector {
this._presentation = {
status: this._storageReadError
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
: 'Ready',
? t(
'Saved project could not be read; storage preserved. Import a valid file to resume saving.',
)
: t('Ready'),
progress: 0,
runtime: '',
playbackActive: false,
@@ -212,7 +218,7 @@ export class SceneDirector {
return;
const scene = this._getSelectedScene();
if (!scene?.releaseLayerIds?.includes(change.layerId)) return;
this.stopScene('Scene layer turned off');
this.stopScene(msg('Scene layer turned off'));
});
}
@@ -240,7 +246,7 @@ export class SceneDirector {
this._controls?.destroy();
this._state.destroy();
this._destroyPromise = Promise.resolve().then(async () => {
this.stopScene('Stopped');
this.stopScene(msg('Stopped'));
this._loadAbort?.abort();
this._loadGeneration++;
this.viewer.camera.cancelFlight();
@@ -316,7 +322,9 @@ export class SceneDirector {
_saveProject() {
if (this._storageReadError) {
this._toastStorageError(
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
t(
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
),
);
return false;
}
@@ -330,13 +338,20 @@ export class SceneDirector {
// Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed —
// tell the user instead of crashing the caller (M11).
// A validation failure is logged with its technical path and check;
// the toast says it in words (sceneSaveError.js).
const invalid = e instanceof SceneDocumentError;
console.warn(
'[Scenes] Could not persist project (storage unavailable):',
invalid
? '[Scenes] Could not persist project (invalid project data):'
: '[Scenes] Could not persist project (storage unavailable):',
e,
);
this._toastStorageError(
e instanceof SceneDocumentError
? `Scene not saved — ${e.message}`
invalid
? t('Scene not saved — {reason}', {
reason: sceneSaveFailureReason(e),
})
: undefined,
);
return false;
@@ -345,7 +360,7 @@ export class SceneDirector {
/** Surface a "scene not saved" notice via the global toast + scene status line. */
_toastStorageError(
message = 'Scene not saved — browser storage unavailable',
message = t('Scene not saved — browser storage unavailable'),
) {
this._updateStatus(message);
try {
@@ -374,7 +389,10 @@ export class SceneDirector {
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded')
this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
this._presentation.status = t('Loaded: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
});
this._publish({
type,
sceneId: scene.id,
@@ -393,7 +411,16 @@ export class SceneDirector {
this._controls = new SceneControls({
subscribe: (listener) => this.subscribe(listener),
read: () => ({
scenes: this._project.scenes,
// The panel shows display titles; shots keep the stored title for
// editing and matching.
scenes: this._project.scenes.map((scene) => ({
...scene,
title: displaySceneTitle(scene),
shots: scene.shots.map((shot) => ({
...shot,
displayTitle: displayShotTitle(shot.title, shot, scene),
})),
})),
selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId,
running: this._running,
@@ -489,6 +516,7 @@ export class SceneDirector {
const scene = {
id: uid('scene'),
// i18n-ignore: stored English, like normalizeProject; displaySceneTitle translates it.
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
shots: [],
};
@@ -767,7 +795,9 @@ export class SceneDirector {
));
if (!inventoryMatches) {
this._updateStatus(
`Cannot update ${recipe.title}: shot inventory changed`,
t('Cannot update {scene}: shot inventory changed', {
scene: t(recipe.title),
}),
);
return {
appended: false,
@@ -798,7 +828,9 @@ export class SceneDirector {
);
if (!sourcePackMatches) {
this._updateStatus(
`Cannot update ${recipe.title}: evidence beats changed`,
t('Cannot update {scene}: evidence beats changed', {
scene: t(recipe.title),
}),
);
return {
appended: false,
@@ -821,7 +853,9 @@ export class SceneDirector {
}));
if (resolvedPatches.some(({ shot }) => !shot)) {
this._updateStatus(
`Cannot update ${recipe.title}: shot bindings are incomplete`,
t('Cannot update {scene}: shot bindings are incomplete', {
scene: t(recipe.title),
}),
);
return {
appended: false,
@@ -901,8 +935,18 @@ export class SceneDirector {
if (announce && saved) {
this._updateStatus(
marker
? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
: `Appended ${appendedShots.length} shots: ${recipe.title}`,
? tn(
patchedShotCount,
'Updated {count} shot: {scene}',
'Updated {count} shots: {scene}',
{ scene: t(recipe.title) },
)
: tn(
appendedShots.length,
'Appended {count} shot: {scene}',
'Appended {count} shots: {scene}',
{ scene: t(recipe.title) },
),
);
}
return {
@@ -924,13 +968,14 @@ export class SceneDirector {
const camera = this.styleManager.getCameraState();
if (!camera) {
this._updateStatus('Cannot capture shot: camera not ready');
this._updateStatus(t('Cannot capture shot: camera not ready'));
return;
}
const shot = normalizeShot(
{
id: uid('shot'),
// i18n-ignore: stored English; displayShotTitle translates it when shown.
title: `Shot ${scene.shots.length + 1}`,
durationSec: DEFAULT_SHOT_DURATION_SEC,
holdSec: DEFAULT_HOLD_SEC,
@@ -945,7 +990,14 @@ export class SceneDirector {
this._selectedShotId = shot.id;
const saved = this._saveProject();
this._shotOutcome('shot-captured', scene, shot);
if (saved) this._updateStatus(`Captured: ${scene.title} / ${shot.title}`);
if (saved) {
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -958,7 +1010,7 @@ export class SceneDirector {
const shot = scene.shots.find((item) => item.id === this._selectedShotId);
if (!shot) {
this._updateStatus('Select a shot first');
this._updateStatus(t('Select a shot first'));
return;
}
@@ -973,7 +1025,14 @@ export class SceneDirector {
const saved = this._saveProject();
this._shotOutcome('shot-updated', scene, shot);
if (saved) this._updateStatus(`Updated: ${scene.title} / ${shot.title}`);
if (saved) {
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1079,7 +1138,11 @@ export class SceneDirector {
const released = await this._releaseSceneLayers(previousScene, token);
if (!released || token.cancelled) {
if (!token.cancelled)
this._updateStatus(`Could not leave scene: ${previousScene.title}`);
this._updateStatus(
t('Could not leave scene: {scene}', {
scene: displaySceneTitle(previousScene),
}),
);
return;
}
this._loadedSceneId = null;
@@ -1129,7 +1192,12 @@ export class SceneDirector {
seeking: true,
});
if (this._loadAbort === controller) this._loadAbort = null;
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
this._updateStatus(
t('Seeked: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
this._activateInteractions(scene, shot);
return { started: true, shotId };
}
@@ -1491,14 +1559,19 @@ export class SceneDirector {
running: false,
seeking: true,
});
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
this._updateStatus(
t('Seeked: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
return true;
}
/** Seek the complete authored scene state to an exact clock position. */
seekScene(sceneId, progress) {
if (this._destroyed) return Promise.resolve(false);
this.stopScene('Seeking scene clock');
this.stopScene(msg('Seeking scene clock'));
const generation = ++this._sceneSeekGeneration;
return this._trackWork(this._seekScene(sceneId, progress, generation));
}
@@ -1568,7 +1641,7 @@ export class SceneDirector {
this._claimingCamera = false;
}
if (claimed === false) {
this._updateStatus('Camera unavailable — exit cockpit first');
this._updateStatus(t('Camera unavailable — exit cockpit first'));
return false;
}
return true;
@@ -1703,7 +1776,7 @@ export class SceneDirector {
if (!queue.length) return { started: false, reason: 'scene-complete' };
}
if (!queue.length) {
this._updateStatus('No shots to run');
this._updateStatus(t('No shots to run'));
return { started: false, reason: 'no-shots' };
}
@@ -1765,6 +1838,7 @@ export class SceneDirector {
// Initialize telemetry accumulator for this run
this._activeRun = {
recipeId: `project-${PROJECT_VERSION}`,
// i18n-ignore: run telemetry record, downloaded as JSON
title: 'Editable Scene Run',
startedAt: new Date().toISOString(),
estimatedDurationSec,
@@ -1787,7 +1861,11 @@ export class SceneDirector {
releaseOnFinish: preview,
});
} catch (error) {
this._updateStatus(`Error: ${error.message || 'run failed'}`);
this._updateStatus(
t('Error: {message}', {
message: error.message || 'run failed',
}),
);
this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
@@ -1833,9 +1911,11 @@ export class SceneDirector {
* enabled while the post-await check skips its params. The manager rolls an
* aborted enable back through the module's own disable().
*
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
* @param {string} [reason='Stopped'] - Cancellation reason, in English: it
* is logged as data and translated only for the status line (callers mark
* theirs with msg()).
*/
stopScene(reason = 'Stopped') {
stopScene(reason = msg('Stopped')) {
this._setSceneMediaPlayback();
this._interactions?.clear();
this._dataPacks?.clear();
@@ -1850,7 +1930,7 @@ export class SceneDirector {
if (!this._running || !this._runToken) return;
this._runToken.cancelled = true;
this._runAbort?.abort();
this._updateStatus(reason);
this._updateStatus(t(reason));
this._logEvent('scene_stopped', { reason });
}
@@ -1907,7 +1987,7 @@ export class SceneDirector {
if (action.type === 'shot') {
if (this._interactionTransitions >= 64) {
this._updateStatus(
'Scene transition limit reached — load a shot to reset',
t('Scene transition limit reached — load a shot to reset'),
);
return false;
}
@@ -1950,7 +2030,7 @@ export class SceneDirector {
try {
payload = stringifySceneDocument(this._project);
} catch (error) {
this._updateStatus(`Export failed: ${error.message}`);
this._updateStatus(t('Export failed: {error}', { error: error.message }));
return;
}
// Trigger a browser download via a temporary anchor element
@@ -1963,7 +2043,7 @@ export class SceneDirector {
link.click();
link.remove();
URL.revokeObjectURL(url);
this._presentation.status = 'Project exported';
this._presentation.status = t('Project exported');
this._publish({ type: 'project-exported', project: this._project });
}
@@ -1993,7 +2073,7 @@ export class SceneDirector {
)
return false;
// Validate before touching playback, selection or the saved project.
this.stopScene('Importing project');
this.stopScene(msg('Importing project'));
await Promise.allSettled([...(this._pendingWork || [])]);
if (this._destroyed || generation !== this._importGeneration) return;
if (
@@ -2023,7 +2103,7 @@ export class SceneDirector {
this._loadedSceneId = null;
const saved = this._saveProject();
this._publish({ type: 'project-imported', project });
if (saved) this._updateStatus(`Imported ${file.name}`);
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
return true;
} catch (error) {
if (
@@ -2034,8 +2114,8 @@ export class SceneDirector {
return false;
this._updateStatus(
error instanceof SceneDocumentError
? `Import failed: ${error.message}`
: 'Import failed (could not read JSON file)',
? t('Import failed: {error}', { error: error.message })
: t('Import failed (could not read JSON file)'),
);
}
}
@@ -2135,7 +2215,9 @@ export class SceneDirector {
}
if (refused.length) {
this._updateStatus(`Layers refused: ${refused.join(', ')}`);
this._updateStatus(
t('Layers refused: {layers}', { layers: refused.join(', ') }),
);
this._logEvent('shot_layers_refused', { layerIds: [...refused] });
}
return { applied, refused, cancelled: false };
@@ -2226,7 +2308,7 @@ export class SceneDirector {
result.error || 'unknown reason',
);
this._updateStatus(
`Could not exit ${mode} — scene layers may be refused`,
t('Could not exit {mode} — scene layers may be refused', { mode }),
);
this._logEvent('context_mode_exit_failed', {
mode,
@@ -2252,7 +2334,7 @@ export class SceneDirector {
token,
);
if (!completed && !token.cancelled && !this._destroyed)
this.stopScene('Camera move interrupted');
this.stopScene(msg('Camera move interrupted'));
}
/**
@@ -2341,7 +2423,7 @@ export class SceneDirector {
const pending = readers.filter(({ read }) => read()?.pending === true);
if (!pending.length) return;
if (pending.some(({ maxWaitMs }) => Date.now() - began >= maxWaitMs)) {
this.stopScene('Scene media timed out');
this.stopScene(msg('Scene media timed out'));
throw new Error(
'Scene media did not finish within its bounded playback window',
);
@@ -0,0 +1,43 @@
// Director storage-failure notices and stop reasons must be marked for
// translation and present in every shipped catalog, so es/ru users do not
// get English toasts or Scene status lines. The stop reason itself stays
// English data (it is logged); only the status line translates it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
extractMessages,
readCatalog,
shippedLocales,
} from '../../scripts/i18n.mjs';
const NOTICES = [
'Scene not saved — browser storage unavailable',
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
'Saved project could not be read; storage preserved. Import a valid file to resume saving.',
'Scene not saved — {reason}',
'Stopped',
'Stopped (Esc)',
'Stopped by voice',
'Camera ownership changed',
'Scene layer turned off',
'Seeking scene clock',
'Importing project',
'Camera move interrupted',
'Scene media timed out',
];
const messages = extractMessages();
for (const text of NOTICES) {
test(`Director notice is marked with t()/msg(): ${text}`, () => {
assert.ok(messages.has(text), `not marked for translation: ${text}`);
});
for (const locale of shippedLocales()) {
test(`${locale} catalog translates: ${text}`, () => {
const value = readCatalog(locale)[text];
assert.ok(value, `missing in ${locale}.json`);
assert.notEqual(value, text);
});
}
}
+15 -8
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
import { createInteractionSession } from '../director/interactions/session.js';
import { t } from '../i18n/index.js';
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
export function createSceneInteractions(
@@ -24,7 +25,7 @@ export function createSceneInteractions(
card.append(text);
if (item.action.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.textContent = t('Source');
link.style.color = '#6eeaff';
link.href = item.action.url;
link.target = '_blank';
@@ -60,8 +61,8 @@ export function createSceneInteractions(
const ok = await session.dispatch(id);
if (current && current === panel && status)
status.textContent = ok
? 'Action complete'
: 'Action unavailable or cancelled';
? t('Action complete')
: t('Action unavailable or cancelled');
return ok;
}
return {
@@ -87,7 +88,7 @@ export function createSceneInteractions(
}
panel = document.createElement('section');
panel.dataset.directorInteractions = '';
panel.setAttribute('aria-label', 'Scene actions');
panel.setAttribute('aria-label', t('Scene actions'));
Object.assign(panel.style, {
position: 'absolute',
left: '12px',
@@ -104,12 +105,13 @@ export function createSceneInteractions(
});
const owner = panel;
const title = document.createElement('strong');
title.textContent = 'Scene actions';
title.textContent = t('Scene actions');
panel.append(title);
status = document.createElement('p');
status.setAttribute('role', 'status');
status.textContent =
'Select a feature or use Tab and Enter to choose an action.';
status.textContent = t(
'Select a feature or use Tab and Enter to choose an action.',
);
panel.append(status);
for (const item of items) {
const button = document.createElement('button');
@@ -157,7 +159,12 @@ export function createSceneInteractions(
? '2px solid #6eeaff'
: '';
}
status.textContent = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
status.textContent = t(
'Selected feature: {feature}. Choose an action.',
{
feature: matches[0].target.featureId,
},
);
buttons.get(matches[0].id)?.focus({ preventScroll: true });
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
+12
View File
@@ -7,6 +7,7 @@ import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
const ADDITIONAL_SHOTS = [
{
id: 'second-landslide',
// i18n-ignore: stored shot title, matched verbatim
title: 'Second landslide',
lat: 28.399234,
lon: 85.535806,
@@ -17,6 +18,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dhunche',
// i18n-ignore: stored shot title, matched verbatim
title: 'Dhunche',
lat: 28.161431,
lon: 85.324278,
@@ -27,6 +29,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'mailung-upper-trishuli',
// i18n-ignore: stored shot title, matched verbatim
title: 'Mailung / Upper Trishuli bridge',
lat: 28.140642,
lon: 85.265996,
@@ -37,6 +40,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'mailung-bazzar',
// i18n-ignore: stored shot title, matched verbatim
title: 'Mailung Bazzar, Dandaguan',
lat: 28.126526,
lon: 85.248027,
@@ -47,6 +51,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dandagaun',
// i18n-ignore: stored shot title, matched verbatim
title: 'Dandagaun, Rasuwa',
lat: 28.124424,
lon: 85.245154,
@@ -57,6 +62,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dandagaun-viewpoint',
// i18n-ignore: stored shot title, matched verbatim
title: 'Dandagaun viewpoint restaurant',
lat: 28.086663,
lon: 85.229914,
@@ -67,6 +73,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'betrawati-bazaar',
// i18n-ignore: stored shot title, matched verbatim
title: 'Betrawati Bazaar',
lat: 28.029827,
lon: 85.227694,
@@ -77,6 +84,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'bhainse',
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhainse',
lat: 28.023087,
lon: 85.229449,
@@ -87,6 +95,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'devighat-taadi-khola-bridge',
// i18n-ignore: stored shot title, matched verbatim
title: 'Devighat / Taadi Khola Bridge',
lat: 27.921132,
lon: 85.177113,
@@ -97,6 +106,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'charaudi',
// i18n-ignore: stored shot title, matched verbatim
title: 'Charaudi',
lat: 27.865095,
lon: 84.838424,
@@ -107,6 +117,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'final-view',
// i18n-ignore: stored shot title, matched verbatim
title: 'Final view',
lat: 28.0834,
lon: 85.206,
@@ -340,6 +351,7 @@ export function expandNepalEvidencePack(base) {
shotPatches: [
...(base.shotPatches || []),
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Final view',
layers: finalView.layers,
},
+10 -2
View File
@@ -1,4 +1,7 @@
/** Connect the portable Director runner to the existing scene presentation. */
import { t } from '../i18n/index.js';
import { displaySceneTitle, displayShotTitle } from './recipes.js';
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
let timing;
let travel;
@@ -14,7 +17,12 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
director._renderSceneSelect();
director._renderShotList();
director._updateStatus(
`Running ${index + 1}/${total}: ${scene.title} / ${shot.title}`,
t('Running {index}/{total}: {scene} / {shot}', {
index: index + 1,
total,
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
director._logEvent('shot_start', {
sceneId: scene.id,
@@ -71,7 +79,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
},
complete() {
director._setProgress(1);
director._updateStatus('Scene run complete');
director._updateStatus(t('Scene run complete'));
director._logEvent('scene_run_complete', {});
},
};
+4
View File
@@ -122,6 +122,7 @@ export function recipeToScene(recipe) {
const mapStack = keyframe.mapStack || post.mapStack;
return {
id: uid('shot'),
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
title: keyframe.title || `Shot ${idx + 1}`,
durationSec: Math.max(
0.2,
@@ -171,6 +172,7 @@ export function recipeToScene(recipe) {
return {
id: recipe.id || uid('scene'),
// i18n-ignore: persisted fallback; built-in titles display through director
title: recipe.title || 'Untitled Scene',
releaseLayerIds: [
...new Set(
@@ -225,6 +227,7 @@ export function normalizeShot(
return {
id: rawShot?.id || uid('shot'),
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
title: rawShot?.title || `Shot ${index + 1}`,
durationSec: Math.max(
0.2,
@@ -349,6 +352,7 @@ export function normalizeProject(rawProject) {
);
return {
id: scene?.id || uid('scene'),
// i18n-ignore: persisted fallback for a saved scene without a title
title: scene?.title || `Scene ${sceneIdx + 1}`,
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
+108 -7
View File
@@ -4,11 +4,12 @@
*/
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
import { msg, t } from '../i18n/index.js';
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
id: 'bhote-koshi-nepal-evidence-pack',
version: 12,
title: 'Bhote Koshi Evidence Sequence',
title: msg('Bhote Koshi Evidence Sequence'),
style: 'normal',
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
layers: {
@@ -26,6 +27,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
cameraPath: [
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Global Incident Context',
lat: 27.5962,
lon: 78.5718,
@@ -43,6 +45,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Nepal-Focused Globe Rotation',
lat: 28.7248,
lon: 81.3226,
@@ -60,6 +63,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Regional Approach',
lat: 27.0354,
lon: 84.6287,
@@ -77,6 +81,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Nearby Cities',
lat: 27.891,
lon: 85.428,
@@ -94,6 +99,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Incident Corridor',
lat: 28.0529,
lon: 85.2189,
@@ -111,6 +117,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Flood Path',
lat: 28.1691,
lon: 85.352,
@@ -128,6 +135,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Corridor Overview',
lat: 27.8986,
lon: 84.8723,
@@ -145,6 +153,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Upper Valley',
lat: 28.4187,
lon: 85.4341,
@@ -221,6 +230,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
shotPatches: [
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Global Incident Context',
visual: { mapStack: 'photoreal' },
layers: {
@@ -236,6 +246,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Nepal-Focused Globe Rotation',
visual: { mapStack: 'photoreal' },
layers: {
@@ -251,6 +262,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Regional Approach',
visual: { mapStack: 'photoreal' },
layers: {
@@ -266,6 +278,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Nearby Cities',
visual: { mapStack: 'photoreal' },
layers: {
@@ -281,6 +294,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Incident Corridor',
camera: {
lat: 28.0529,
@@ -305,6 +319,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Flood Path',
visual: { mapStack: 'photoreal' },
layers: {
@@ -320,6 +335,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Corridor Overview',
visual: { mapStack: 'photoreal' },
layers: {
@@ -335,6 +351,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bhote Koshi Upper Valley',
holdSec: 8,
visual: { mapStack: 'photoreal' },
@@ -357,6 +374,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Upper Valley Collapse',
camera: {
lat: 28.417,
@@ -380,24 +398,29 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Nepal-China Border Gate',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Timure Evidence Cluster',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Syabru Besi Passage',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bidur / Trishuli Consequence',
visual: { mapStack: 'photoreal' },
},
],
cameraPath: [
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Upper Valley Collapse',
lat: 28.417,
lon: 85.3937,
@@ -421,6 +444,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Debris-Dammed Lake',
lat: 28.392914,
lon: 85.570921,
@@ -444,6 +468,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Nepal-China Border Gate',
mapStack: 'photoreal',
lat: 28.316789,
@@ -474,6 +499,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Timure Evidence Cluster',
mapStack: 'photoreal',
lat: 28.285968,
@@ -502,6 +528,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Syabru Besi Passage',
mapStack: 'photoreal',
lat: 28.210306,
@@ -530,6 +557,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: stored shot title, matched verbatim
title: 'Bidur / Trishuli Consequence',
mapStack: 'photoreal',
lat: 27.981737,
@@ -569,7 +597,7 @@ const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
? Object.freeze({
id: 'bhote-koshi-nepal-scene',
title: 'Nepal Flood Incident',
title: msg('Nepal Flood Incident'),
durationSec: 15,
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
@@ -583,6 +611,7 @@ const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
.slice(0, 3)
.map((keyframe, index) => ({
...keyframe,
// i18n-ignore: legacy shot titles the bootstrap matches verbatim
title: `Shot ${index + 1}`,
})),
})
@@ -593,7 +622,7 @@ const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
const PUBLIC_SCENE_RECIPES = [
{
id: 'flights-radar',
title: 'Global Flights Radar',
title: msg('Global Flights Radar'),
durationSec: 30,
style: 'retro',
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
@@ -663,7 +692,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'orbital-watch',
title: 'Orbital Watch',
title: msg('Orbital Watch'),
durationSec: 32,
style: 'surveillance',
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
@@ -741,7 +770,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'thermal-threats',
title: 'Thermal Threat Board',
title: msg('Thermal Threat Board'),
durationSec: 26,
style: 'thermal',
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
@@ -809,7 +838,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'city-overload',
title: 'City Overload',
title: msg('City Overload'),
durationSec: 30,
style: 'surveillance',
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
@@ -887,7 +916,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'omniscience-pullback',
title: 'Omniscience Pullback',
title: msg('Omniscience Pullback'),
durationSec: 36,
style: 'retro',
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
@@ -982,3 +1011,75 @@ export function getSceneRecipeById(id) {
export function getSceneAppendRecipeById(id) {
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
}
// Built-in shot titles stay English in recipes and saved projects (upgrades
// and bindings match them verbatim). These are their display names; a title
// the user typed shows as typed. Place names inside them stay Latin.
const BUILTIN_SHOT_TITLES = new Set([
msg('Global Incident Context'),
msg('Nepal-Focused Globe Rotation'),
msg('Bhote Koshi Regional Approach'),
msg('Bhote Koshi Nearby Cities'),
msg('Bhote Koshi Incident Corridor'),
msg('Bhote Koshi Flood Path'),
msg('Bhote Koshi Corridor Overview'),
msg('Bhote Koshi Upper Valley'),
msg('Upper Valley Collapse'),
msg('Debris-Dammed Lake'),
msg('Nepal-China Border Gate'),
msg('Timure Evidence Cluster'),
msg('Syabru Besi Passage'),
msg('Bidur / Trishuli Consequence'),
msg('Second landslide'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Devighat / Taadi Khola Bridge'),
msg('Final view'),
]);
/**
* Display title for a scene: the localized recipe name while the stored title
* still matches the built-in verbatim, the stored title otherwise.
* @param {{id?: string, title?: string}} scene
* @returns {string}
*/
export function displaySceneTitle(scene) {
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
if (builtin?.title && scene.title === builtin.title) return t(builtin.title);
const title = scene?.title || '';
// Only the generated "Scene N" localizes; an operator-typed "Scene 007"
// keeps its digits, so the number stays a string (no Number() round trip).
const numbered = /^Scene ([1-9]\d*)$/.exec(title);
return numbered ? t('Scene {n}', { n: numbered[1] }) : title;
}
/**
* Display title for a shot: built-in titles and the numbered "Shot N"
* fallback localize; any other title shows as stored.
*
* With `shot`, a built-in title localizes only for a recipe shot
* (`sourcePackId`), so a typed title that matches a built-in one stays as
* typed. Without `shot`, the title alone decides.
* @param {string} title
* @param {{sourcePackId?: string}} [shot]
* @param {{id?: string}} [scene] The shot's scene, for projects saved before
* shots recorded their source pack.
* @returns {string}
*/
export function displayShotTitle(title, shot = undefined, scene = undefined) {
const text = typeof title === 'string' ? title : '';
const fromRecipe = shot
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene?.id, text)
: true;
if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text);
const numbered = /^Shot ([1-9]\d*)$/.exec(text);
return numbered ? t('Shot {n}', { n: numbered[1] }) : text;
}
/** Whether the built-in recipe behind a scene declares a shot with this title. */
function recipeDeclaresShotTitle(sceneId, title) {
const recipe = sceneId ? getSceneRecipeById(sceneId) : null;
return Boolean(
recipe?.cameraPath?.some((keyframe) => keyframe?.title === title),
);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* The reason for «Scene not saved — {reason}» when the project fails
* validation. The validator's text («$.scenes[6].title: expected …») is for
* the console: known failures get a sentence with 1-based numbers, others a
* general one (English keeps the raw text). director.js logs the full error.
*/
import { formatNumber, getLocale, t } from '../i18n/index.js';
const TEXT_LIMIT = /^expected nonempty text, at most (\d+) characters$/;
const SCENE_TITLE = /^\$\.scenes\[(\d+)\]\.title$/;
const SHOT_TITLE = /^\$\.scenes\[(\d+)\]\.shots\[(\d+)\]\.title$/;
const ordinal = (index) => formatNumber(Number(index) + 1);
/**
* @param {unknown} error Usually a SceneDocumentError ({path, reason}).
* @returns {string} Reason in the UI language.
*/
export function sceneSaveFailureReason(error) {
const path = typeof error?.path === 'string' ? error.path : '';
const reason =
typeof error?.reason === 'string'
? error.reason
: String(error?.message || '').slice(path ? path.length + 2 : 0);
const limit = TEXT_LIMIT.exec(reason);
if (limit) {
const max = formatNumber(Number(limit[1]));
const scene = SCENE_TITLE.exec(path);
if (scene)
return t(
'the title of scene {scene} is empty or longer than {max} characters',
{ scene: ordinal(scene[1]), max },
);
const shot = SHOT_TITLE.exec(path);
if (shot)
return t(
'the title of shot {shot} in scene {scene} is empty or longer than {max} characters',
{ scene: ordinal(shot[1]), shot: ordinal(shot[2]), max },
);
}
if (path === '$' && reason === 'file exceeds 5 MiB')
return t('the project is larger than 5 MiB');
if (reason === 'too many shots in project')
return t('the project has too many shots');
// English keeps the validator's own text, which names the field; another
// language gets a general sentence (the field is in the console warning).
if (getLocale() === 'en' && error?.message) return String(error.message);
return t('the scene data failed validation');
}
+101
View File
@@ -0,0 +1,101 @@
// «Scene not saved — {reason}» never shows the validator's JSON path: known
// failures read as a sentence, the rest get a general reason.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import {
SceneDocumentError,
validateSceneDocument,
} from '../director/document.js';
import { sceneSaveFailureReason } from './sceneSaveError.js';
const caught = (project) => {
try {
validateSceneDocument(project);
} catch (error) {
return error;
}
return assert.fail('expected a validation error');
};
test('an empty scene title is named with its 1-based scene number', () => {
setLocale('en');
const scenes = Array.from({ length: 7 }, (_, i) => ({
id: `s${i}`,
title: i === 6 ? ' ' : `Scene ${i + 1}`,
shots: [],
}));
const error = caught({ version: 6, scenes });
assert.ok(error instanceof SceneDocumentError);
assert.equal(error.path, '$.scenes[6].title');
assert.equal(
sceneSaveFailureReason(error),
'the title of scene 7 is empty or longer than 4,096 characters',
);
});
test('a shot title names the shot and its scene', () => {
setLocale('en');
const error = caught({
version: 6,
scenes: [
{
id: 'a',
title: 'A',
shots: [{ id: 'x' }, { id: 'y', title: 'z'.repeat(4097) }],
},
],
});
assert.equal(
sceneSaveFailureReason(error),
'the title of shot 2 in scene 1 is empty or longer than 4,096 characters',
);
});
test('size and shot-count limits read as sentences', () => {
setLocale('en');
assert.equal(
sceneSaveFailureReason(new SceneDocumentError('$', 'file exceeds 5 MiB')),
'the project is larger than 5 MiB',
);
assert.equal(
sceneSaveFailureReason(
new SceneDocumentError('$.scenes[3].shots', 'too many shots in project'),
),
'the project has too many shots',
);
});
test('any other validation failure: English keeps the field, a translation never shows the path', () => {
const errors = [
new SceneDocumentError(
'$.scenes[0].shots[2].layers.x.enabled',
'expected a boolean',
),
new SceneDocumentError('$.scenes[1].anchors[0].id', 'duplicate ID'),
new Error('$.scenes[0]: odd'),
];
setLocale('en');
for (const error of errors)
assert.equal(sceneSaveFailureReason(error), error.message);
assert.equal(
sceneSaveFailureReason(null),
'the scene data failed validation',
);
for (const locale of ['ru', 'es']) {
setLocale(locale);
try {
for (const error of [...errors, null]) {
const text = sceneSaveFailureReason(error);
assert.doesNotMatch(
text,
/\$\.|expected|duplicate|odd/,
`${locale}: ${text}`,
);
}
} finally {
setLocale('en');
}
}
});
+143
View File
@@ -0,0 +1,143 @@
// Display helpers must not rewrite operator-typed scene/shot titles: only the
// generated "Scene N" / "Shot N" and the titles of recipe-sourced shots
// localize; everything else shows exactly as stored.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SCENE_RECIPES,
displaySceneTitle,
displayShotTitle,
} from './recipes.js';
import { setLocale } from '../i18n/index.js';
test.afterEach(() => setLocale('en'));
test('user-typed numbered titles keep their digits in en', () => {
setLocale('en');
assert.equal(
displaySceneTitle({ id: 'scene-user', title: 'Scene 007' }),
'Scene 007',
);
assert.equal(displayShotTitle('Shot 01'), 'Shot 01');
// default generated names still work
assert.equal(
displaySceneTitle({ id: 'scene-user', title: 'Scene 3' }),
'Scene 3',
);
assert.equal(displayShotTitle('Shot 2'), 'Shot 2');
});
test('generated numbered titles localize, typed ones with leading zeros do not', () => {
setLocale('ru');
assert.equal(
displaySceneTitle({ id: 'scene-user', title: 'Scene 3' }),
'Сцена 3',
);
assert.equal(displayShotTitle('Shot 2'), 'Кадр 2');
assert.equal(
displaySceneTitle({ id: 'scene-user', title: 'Scene 007' }),
'Scene 007',
);
assert.equal(displayShotTitle('Shot 01'), 'Shot 01');
});
test('a built-in shot title localizes only for a recipe-sourced shot', () => {
setLocale('ru');
// A built-in shot title (BUILTIN_SHOT_TITLES in recipes.js).
const title = 'Final view';
const userScene = { id: 'scene-user' };
// Operator-typed title that happens to match a built-in one: shown as typed.
assert.equal(
displayShotTitle(title, { id: 'shot-user', title }, userScene),
title,
);
// A recipe-sourced shot: localized.
const fromRecipe = displayShotTitle(
title,
{ id: 'shot-recipe', title, sourcePackId: 'bhote-koshi-nepal-scene' },
userScene,
);
assert.notEqual(fromRecipe, title);
// Without provenance the title alone decides.
assert.equal(displayShotTitle(title), fromRecipe);
// A project saved before shots recorded their source pack: the scene's
// recipe still vouches for a title its keyframes declare (only when such a
// recipe ships in this build).
const named = (keyframe) =>
keyframe?.title && !/^Shot \d+$/.test(keyframe.title);
const recipe = SCENE_RECIPES.find((item) => item.cameraPath?.some(named));
if (recipe) {
const declared = recipe.cameraPath.find(named).title;
assert.equal(
displayShotTitle(
declared,
{ id: 'shot-old', title: declared },
{ id: recipe.id },
),
displayShotTitle(declared),
);
assert.equal(
displayShotTitle(
declared,
{ id: 'shot-old', title: declared },
userScene,
),
declared,
);
}
});
test('the Scene status line shows an operator-typed shot title as typed', async () => {
const { createScenePlaybackAdapter } = await import('./playbackAdapter.js');
const statuses = [];
const director = {
_renderSceneSelect() {},
_renderShotList() {},
_updateStatus: (text) => statuses.push(text),
_logEvent() {},
};
const adapter = createScenePlaybackAdapter(director, 4);
const title = 'Final view';
const scene = { id: 'scene-user', title: 'Scene 1', shots: [] };
setLocale('ru');
adapter.selectShot({
scene,
shot: { id: 'shot-user', title },
index: 0,
total: 1,
});
adapter.selectShot({
scene,
shot: { id: 'shot-recipe', title, sourcePackId: 'bhote-koshi-nepal-scene' },
index: 0,
total: 1,
});
assert.ok(statuses[0].endsWith(` / ${title}`), statuses[0]);
assert.ok(
statuses[1].endsWith(` / ${displayShotTitle(title)}`) &&
!statuses[1].endsWith(` / ${title}`),
statuses[1],
);
});
test('the scene context of a shot carries its display title by provenance', async () => {
const { layerStatesForShot } = await import('./shotPresentation.js');
const title = 'Final view';
const packs = { recipeForShot: () => null, minimumHoldSec: () => 0 };
const contextFor = (shot) => {
const scene = { id: 'scene-user', title: 'Scene 1', shots: [shot] };
const states = layerStatesForShot(
scene,
{ ...shot, layers: { a: { params: { presentation: 'scene-beat' } } } },
packs,
);
return states.a.params.sceneContext;
};
setLocale('ru');
assert.equal(contextFor({ id: 'u', title }).shotDisplayTitle, title);
assert.notEqual(
contextFor({ id: 'r', title, sourcePackId: 'bhote-koshi-nepal-scene' })
.shotDisplayTitle,
title,
);
});
+71 -35
View File
@@ -12,6 +12,7 @@ import {
} from '../director/authoring.js';
import { createSceneDialog, mountSceneSharing } from '../ui/sceneSharing.js';
import { PACK_LIMITS } from '../director/packs/manifest.js';
import { t, tn } from '../i18n/index.js';
function download(text, name) {
const url = URL.createObjectURL(
@@ -91,7 +92,9 @@ export function createSceneSharing(director) {
dialog.status.textContent =
error?.name === 'SceneDocumentError'
? error.message
: 'Could not complete this action. Check the file, references and selected assets.';
: t(
'Could not complete this action. Check the file, references and selected assets.',
);
} finally {
if (alive(owner)) busy = false;
}
@@ -103,39 +106,64 @@ export function createSceneSharing(director) {
layerIds: director.dataManager.getAll().map((l) => l.id),
});
dialog.text(
`${report.scenes} scenes · ${report.shots} shots · ${report.packs.length} data packs`,
[
tn(report.scenes, '{count} scene', '{count} scenes'),
tn(report.shots, '{count} shot', '{count} shots'),
tn(report.packs.length, '{count} data pack', '{count} data packs'),
].join(' · '),
);
for (const pack of report.packs)
dialog.text(
`${pack.scene} / ${pack.id}: ${pack.status}. File: ${pack.path}. ${pack.attribution.text} · ${pack.attribution.license}`,
t(
'{scene} / {pack}: {status}. File: {path}. {attribution} · {license}',
{
scene: pack.scene,
pack: pack.id,
status: t(pack.status),
path: pack.path,
attribution: pack.attribution.text,
license: pack.attribution.license,
},
),
);
if (report.missingLayers.length)
dialog.text(`Unavailable layers: ${report.missingLayers.join(', ')}`);
dialog.text(
t('Unavailable layers: {layers}', {
layers: report.missingLayers.join(', '),
}),
);
if (report.externalContent)
dialog.text(
'This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.',
t(
'Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.',
),
);
if (report.bundledBytes)
dialog.text(
`${report.bundledBytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.`,
t(
'{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.',
{ bytes: report.bundledBytes },
),
);
return report;
}
async function preview(file) {
const owner = open('Review scene import');
const owner = open(t('Review scene import'));
if (!owner) return;
const expected = currentProject();
dialog.text('Nothing is loaded or changed until you apply this file.');
dialog.text(t('Nothing is loaded or changed until you apply this file.'));
await run(owner, async () => {
const input = await readSceneShare(file, { signal: owner.signal });
if (!alive(owner)) return;
staged = input;
inventory(input);
dialog.status.textContent = 'Ready to import';
dialog.status.textContent = t('Ready to import');
dialog.text(
'Apply replaces the current project. Export your current project first if you want to keep both.',
t(
'Apply replaces the current project. Export your current project first if you want to keep both.',
),
);
const apply = dialog.button('Apply import', () =>
const apply = dialog.button(t('Apply import'), () =>
run(owner, async () => {
if (expected !== currentProject()) throw new Error('Project changed');
apply.disabled = true;
@@ -148,8 +176,9 @@ export function createSceneSharing(director) {
if (ok) close();
else {
apply.disabled = false;
dialog.status.textContent =
'Import was not applied. The current project may have changed.';
dialog.status.textContent = t(
'Import was not applied. The current project may have changed.',
);
}
}
}),
@@ -161,29 +190,31 @@ export function createSceneSharing(director) {
const scene = director._getSelectedScene(),
shot = scene?.shots.find((s) => s.id === director._selectedShotId);
if (!scene || !shot) {
director._updateStatus('Select a scene and shot first');
director._updateStatus(t('Select a scene and shot first'));
return;
}
const owner = open('Edit scene details');
const owner = open(t('Edit scene details'));
if (!owner) return;
const original = structuredClone(director._project),
expected = currentProject();
dialog.text(
'Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.',
t(
'Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.',
),
);
const sceneText = dialog.input(
'Anchors and data packs',
t('Anchors and data packs'),
json(subset(scene, sceneKeys)),
{ multiline: true },
);
const shotText = dialog.input(
'Shot camera, timing, packs and actions',
t('Shot camera, timing, packs and actions'),
json(subset(shot, shotKeys)),
{ multiline: true },
);
const anchorName = dialog.input('New anchor ID', 'anchor-1');
const anchorName = dialog.input(t('New anchor ID'), 'anchor-1');
dialog.button(
'Capture camera as anchor',
t('Capture camera as anchor'),
() =>
run(owner, async () => {
const details = JSON.parse(sceneText.value),
@@ -210,12 +241,12 @@ export function createSceneSharing(director) {
JSON.parse(shotText.value),
);
sceneText.value = json(details);
dialog.status.textContent = 'Anchor added to draft';
dialog.status.textContent = t('Anchor added to draft');
}),
dialog.body,
);
dialog.button(
'Set move start to current camera',
t('Set move start to current camera'),
() =>
run(owner, async () => {
const details = JSON.parse(shotText.value),
@@ -246,13 +277,14 @@ export function createSceneSharing(director) {
details,
);
shotText.value = json(details);
dialog.status.textContent =
'Move added to draft; destination remains the shot camera';
dialog.status.textContent = t(
'Move added to draft; destination remains the shot camera',
);
}),
dialog.body,
);
dialog.button(
'Use ordinary flight',
t('Use ordinary flight'),
() => {
if (!alive(owner)) return;
try {
@@ -260,12 +292,12 @@ export function createSceneSharing(director) {
delete details.move;
shotText.value = json(details);
} catch {
dialog.status.textContent = 'Invalid shot JSON';
dialog.status.textContent = t('Invalid shot JSON');
}
},
dialog.body,
);
const apply = dialog.button('Apply details', () =>
const apply = dialog.button(t('Apply details'), () =>
run(owner, async () => {
if (expected !== currentProject()) throw new Error('Project changed');
const project = editSceneDetails(
@@ -300,10 +332,10 @@ export function createSceneSharing(director) {
director._selectedSceneId,
);
} catch {
director._updateStatus('Select a scene first');
director._updateStatus(t('Select a scene first'));
return;
}
const owner = open('Share selected scene');
const owner = open(t('Share selected scene'));
if (!owner) return;
const paths = new Set(
project.scenes.flatMap((s) =>
@@ -320,19 +352,23 @@ export function createSceneSharing(director) {
staged = { project, assets: existing };
inventory(staged);
dialog.text(
'Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.',
t(
'Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.',
),
);
dialog.button('Download scene JSON', () => {
dialog.button(t('Download scene JSON'), () => {
if (alive(owner)) download(stringifySceneDocument(project), 'scene.json');
});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
const files = dialog.input(t('Choose data-pack files'), '', {
type: 'file',
});
files.multiple = true;
const folder = dialog.input('Or choose a data-pack folder', '', {
const folder = dialog.input(t('Or choose a data-pack folder'), '', {
type: 'file',
});
folder.multiple = true;
folder.setAttribute('webkitdirectory', '');
dialog.button('Download asset bundle', () =>
dialog.button(t('Download asset bundle'), () =>
run(owner, async () => {
const selected = [...files.files, ...folder.files];
const packs = project.scenes.flatMap((s) => s.dataPacks || []);
@@ -374,7 +410,7 @@ export function createSceneSharing(director) {
);
if (alive(owner)) {
download(text, 'scene.gevbundle.json');
dialog.status.textContent = 'Asset bundle downloaded';
dialog.status.textContent = t('Asset bundle downloaded');
}
}),
);
+4
View File
@@ -1,3 +1,5 @@
import { displaySceneTitle, displayShotTitle } from './recipes.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const deepClone = (value) => JSON.parse(JSON.stringify(value));
@@ -63,8 +65,10 @@ export function layerStatesForShot(
state.params.sceneContext = {
sceneId: scene.id,
sceneTitle: scene.title,
sceneDisplayTitle: displaySceneTitle(scene),
shotId: shot.id,
shotTitle: shot.title,
shotDisplayTitle: displayShotTitle(shot.title, shot, scene),
shotIndex,
shotCount: scene.shots.length,
durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,