mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
+37
-23
@@ -28,7 +28,7 @@ import {
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { t, tn } from '../i18n/index.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';
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
stringifySceneDocument,
|
||||
SceneDocumentError,
|
||||
} from '../director/document.js';
|
||||
import { sceneSaveFailureReason } from './sceneSaveError.js';
|
||||
|
||||
// Built-in recipe DISPLAY names. Recipe ids, the URL 'scene' param, stored
|
||||
// project titles, and voice-matching titles all stay English (keep-English
|
||||
@@ -131,7 +132,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);
|
||||
@@ -180,7 +181,9 @@ export class SceneDirector {
|
||||
|
||||
this._presentation = {
|
||||
status: this._storageReadError
|
||||
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
|
||||
? t(
|
||||
'Saved project could not be read; storage preserved. Import a valid file to resume saving.',
|
||||
)
|
||||
: t('Ready'),
|
||||
progress: 0,
|
||||
runtime: '',
|
||||
@@ -219,7 +222,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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,7 +250,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();
|
||||
@@ -323,7 +326,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;
|
||||
}
|
||||
@@ -336,13 +341,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,
|
||||
);
|
||||
}
|
||||
@@ -350,7 +362,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 {
|
||||
@@ -381,7 +393,7 @@ export class SceneDirector {
|
||||
if (type === 'shot-loaded')
|
||||
this._presentation.status = t('Loaded: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
});
|
||||
this._publish({
|
||||
type,
|
||||
@@ -409,7 +421,7 @@ export class SceneDirector {
|
||||
title: displaySceneTitle(scene),
|
||||
shots: scene.shots.map((shot) => ({
|
||||
...shot,
|
||||
displayTitle: displayShotTitle(shot.title),
|
||||
displayTitle: displayShotTitle(shot.title, shot, scene),
|
||||
})),
|
||||
})),
|
||||
selectedSceneId: this._selectedSceneId,
|
||||
@@ -984,7 +996,7 @@ export class SceneDirector {
|
||||
this._updateStatus(
|
||||
t('Captured: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1017,7 +1029,7 @@ export class SceneDirector {
|
||||
this._updateStatus(
|
||||
t('Updated: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1182,7 +1194,7 @@ export class SceneDirector {
|
||||
this._updateStatus(
|
||||
t('Seeked: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
this._activateInteractions(scene, shot);
|
||||
@@ -1549,7 +1561,7 @@ export class SceneDirector {
|
||||
this._updateStatus(
|
||||
t('Seeked: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
@@ -1558,7 +1570,7 @@ export class SceneDirector {
|
||||
/** 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));
|
||||
}
|
||||
@@ -1895,9 +1907,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();
|
||||
@@ -1912,7 +1926,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 });
|
||||
}
|
||||
|
||||
@@ -2055,7 +2069,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 (
|
||||
@@ -2316,7 +2330,7 @@ export class SceneDirector {
|
||||
token,
|
||||
);
|
||||
if (!completed && !token.cancelled && !this._destroyed)
|
||||
this.stopScene('Camera move interrupted');
|
||||
this.stopScene(msg('Camera move interrupted'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2405,7 +2419,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
index: index + 1,
|
||||
total,
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
director._logEvent('shot_start', {
|
||||
|
||||
+27
-6
@@ -1047,19 +1047,40 @@ 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 || '';
|
||||
const numbered = /^Scene (\d+)$/.exec(title);
|
||||
return numbered ? t('Scene {n}', { n: Number(numbered[1]) }) : 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 the shot itself, a built-in title localizes only when the shot came
|
||||
* from a recipe (`sourcePackId`), so an operator-typed title that happens to
|
||||
* match a built-in one is never masked by the catalog. Without it, provenance
|
||||
* is unknown and the title alone decides, as before.
|
||||
* @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) {
|
||||
export function displayShotTitle(title, shot = undefined, scene = undefined) {
|
||||
const text = typeof title === 'string' ? title : '';
|
||||
if (BUILTIN_SHOT_TITLES.has(text)) return t(text);
|
||||
const numbered = /^Shot (\d+)$/.exec(text);
|
||||
return numbered ? t('Shot {n}', { n: Number(numbered[1]) }) : text;
|
||||
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,56 @@
|
||||
/**
|
||||
* The reason shown in «Scene not saved — {reason}» when the project fails its
|
||||
* own validation on save.
|
||||
*
|
||||
* A SceneDocumentError names a JSON path and an English check
|
||||
* («$.scenes[6].title: expected nonempty text, at most 4096 characters») —
|
||||
* right for the console and for import diagnostics, meaningless in a toast.
|
||||
* The failures an author can cause from the panel get a plain sentence with
|
||||
* 1-based scene and shot numbers. Anything else keeps the technical text in
|
||||
* English and gets a general sentence in a translation; director.js logs the
|
||||
* error either way.
|
||||
*/
|
||||
|
||||
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,102 @@
|
||||
// Proposal 8: «Scene not saved — {reason}» showed the validator's JSON path
|
||||
// and English check. Known failures read as a sentence; the rest get a
|
||||
// general reason (the technical text is logged by director.js).
|
||||
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, as before.
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -68,7 +68,7 @@ export function layerStatesForShot(
|
||||
sceneDisplayTitle: displaySceneTitle(scene),
|
||||
shotId: shot.id,
|
||||
shotTitle: shot.title,
|
||||
shotDisplayTitle: displayShotTitle(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