mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into fix/director-scene-results-971
# Conflicts: # src/scenes/director.js
This commit is contained in:
commit
75b1c66006
700 files changed
+66498
-6002
No files matched your search
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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', {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user