feat(i18n): translate the whole interface

Mark every UI string the unmarked-text check reported (534) plus text it
cannot see (helpers, joined labels, runtime-built panels), and translate
the result to Spanish and Russian: 1,679 messages each.

- Kept on purpose, with the reason where the text lives: provider and
  product names, place and camera names, codes and units, dB values, the HUD
  terminal readouts, demo shot titles that saved projects look up by title,
  and text for AI tools and the voice model.
- Modules shared with the server or AI tools stay free of the i18n import
  (local msg); the wind reading and voice session stay English and are
  translated where the page shows them.
- npm test now runs the unmarked-text check.
- Fixes on the way: a duplicated radio privacy paragraph, the contact panel
  toggle saying Expand while open, the CSS filter mistaking & for CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 19:24:42 +04:00
1 parent ee72133574
commit 3129e531e6
99 files changed
+4670 -1294

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';
+49 -27
View File
@@ -26,7 +26,7 @@ import {
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { t, msg } from '../i18n/index.js';
import { t, tn } from '../i18n/index.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createSceneInteractions } from './interactions.js';
import { createSceneSharing } from './sharing.js';
@@ -66,13 +66,7 @@ import {
// project titles, and voice-matching titles all stay English (keep-English
// boundary + persisted data); only the presentation of an UNRENAMED built-in
// localizes, so an operator's renamed scene is never masked by the catalog.
const RECIPE_TITLE_KEYS = Object.freeze({
'flights-radar': msg('Global Flights Radar'),
'orbital-watch': msg('Orbital Watch'),
'thermal-threats': msg('Thermal Threat Board'),
'city-overload': msg('City Overload'),
'omniscience-pullback': msg('Omniscience Pullback'),
});
// Recipe titles are marked with msg() in recipes.js.
/**
* Display title for a scene: the localized recipe name while the stored title
@@ -81,10 +75,9 @@ const RECIPE_TITLE_KEYS = Object.freeze({
* @returns {string}
*/
function displaySceneTitle(scene) {
const key = RECIPE_TITLE_KEYS[scene?.id];
if (!key) return scene?.title || '';
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene.id);
return scene.title === builtin?.title ? t(key) : scene.title;
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
if (!builtin?.title) return scene?.title || '';
return scene.title === builtin.title ? t(builtin.title) : scene.title;
}
/** @constant {string} localStorage key for the serialized project */
@@ -519,7 +512,9 @@ export class SceneDirector {
const scene = {
id: uid('scene'),
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
title:
sceneName.trim() ||
t('Scene {n}', { n: this._project.scenes.length + 1 }),
shots: [],
};
@@ -797,7 +792,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,
@@ -828,7 +825,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,
@@ -851,7 +850,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,
@@ -931,8 +932,18 @@ export class SceneDirector {
if (announce) {
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 {
@@ -1121,7 +1132,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: previousScene.title,
}),
);
return;
}
this._loadedSceneId = null;
@@ -1171,7 +1186,9 @@ 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: scene.title, shot: shot.title }),
);
this._activateInteractions(scene, shot);
return { started: true, shotId };
}
@@ -1533,7 +1550,9 @@ export class SceneDirector {
running: false,
seeking: true,
});
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
this._updateStatus(
t('Seeked: {scene} / {shot}', { scene: scene.title, shot: shot.title }),
);
return true;
}
@@ -1807,6 +1826,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,
@@ -1950,7 +1970,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;
}
@@ -1993,7 +2013,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
@@ -2066,7 +2086,7 @@ export class SceneDirector {
this._loadedSceneId = null;
this._saveProject();
this._publish({ type: 'project-imported', project });
this._updateStatus(`Imported ${file.name}`);
this._updateStatus(t('Imported {file}', { file: file.name }));
return true;
} catch (error) {
if (
@@ -2077,8 +2097,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)'),
);
}
}
@@ -2178,7 +2198,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 };
+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: shot title (mostly a place name), matched by title on upgrade
title: 'Second landslide',
lat: 28.399234,
lon: 85.535806,
@@ -17,6 +18,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dhunche',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Dhunche',
lat: 28.161431,
lon: 85.324278,
@@ -27,6 +29,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'mailung-upper-trishuli',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Mailung / Upper Trishuli bridge',
lat: 28.140642,
lon: 85.265996,
@@ -37,6 +40,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'mailung-bazzar',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Mailung Bazzar, Dandaguan',
lat: 28.126526,
lon: 85.248027,
@@ -47,6 +51,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dandagaun',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Dandagaun, Rasuwa',
lat: 28.124424,
lon: 85.245154,
@@ -57,6 +62,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dandagaun-viewpoint',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Dandagaun viewpoint restaurant',
lat: 28.086663,
lon: 85.229914,
@@ -67,6 +73,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'betrawati-bazaar',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Betrawati Bazaar',
lat: 28.029827,
lon: 85.227694,
@@ -77,6 +84,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'bhainse',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Bhainse',
lat: 28.023087,
lon: 85.229449,
@@ -87,6 +95,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'devighat-taadi-khola-bridge',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Devighat / Taadi Khola Bridge',
lat: 27.921132,
lon: 85.177113,
@@ -97,6 +106,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'charaudi',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Charaudi',
lat: 27.865095,
lon: 84.838424,
@@ -107,6 +117,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'final-view',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Final view',
lat: 28.0834,
lon: 85.206,
@@ -340,6 +351,7 @@ export function expandNepalEvidencePack(base) {
shotPatches: [
...(base.shotPatches || []),
{
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Final view',
layers: finalView.layers,
},
+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) } : {}),
+36 -7
View File
@@ -4,11 +4,12 @@
*/
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
import { msg } 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: shot title, matched by title when the recipe upgrades a saved project
title: 'Global Incident Context',
lat: 27.5962,
lon: 78.5718,
@@ -43,6 +45,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
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: shot title, matched by title when the recipe upgrades a saved project
title: 'Global Incident Context',
visual: { mapStack: 'photoreal' },
layers: {
@@ -236,6 +246,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-Focused Globe Rotation',
visual: { mapStack: 'photoreal' },
layers: {
@@ -251,6 +262,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Regional Approach',
visual: { mapStack: 'photoreal' },
layers: {
@@ -266,6 +278,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Nearby Cities',
visual: { mapStack: 'photoreal' },
layers: {
@@ -281,6 +294,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Incident Corridor',
camera: {
lat: 28.0529,
@@ -305,6 +319,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Flood Path',
visual: { mapStack: 'photoreal' },
layers: {
@@ -320,6 +335,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Corridor Overview',
visual: { mapStack: 'photoreal' },
layers: {
@@ -335,6 +351,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Upper Valley',
holdSec: 8,
visual: { mapStack: 'photoreal' },
@@ -357,6 +374,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Upper Valley Collapse',
camera: {
lat: 28.417,
@@ -380,24 +398,29 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-China Border Gate',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Timure Evidence Cluster',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Syabru Besi Passage',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bidur / Trishuli Consequence',
visual: { mapStack: 'photoreal' },
},
],
cameraPath: [
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Upper Valley Collapse',
lat: 28.417,
lon: 85.3937,
@@ -421,6 +444,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Debris-Dammed Lake',
lat: 28.392914,
lon: 85.570921,
@@ -444,6 +468,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-China Border Gate',
mapStack: 'photoreal',
lat: 28.316789,
@@ -474,6 +499,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Timure Evidence Cluster',
mapStack: 'photoreal',
lat: 28.285968,
@@ -502,6 +528,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Syabru Besi Passage',
mapStack: 'photoreal',
lat: 28.210306,
@@ -530,6 +557,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
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' },
+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: 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(
'This scene 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');
}
}),
);