complete editor internationalization (#43)

Co-authored-by: haixin.yang <haixin.yang@weimob.com>
This commit is contained in:
Martin Delophy
2026-07-23 11:03:26 +08:00
committed by GitHub
co-authored by haixin.yang
parent 11ddf231e0
commit c02e6136a1
17 changed files with 7329 additions and 14 deletions
+98
View File
@@ -0,0 +1,98 @@
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
const RAW_KEY_ENGLISH = {
cancel: "Cancel",
canvasRatio: "Canvas ratio",
clipDuration: "Clip duration",
clipStart: "Clip start",
layer: "Layer",
lock: "Lock",
visualBasic: "Basic",
};
const TARGET_CODES = { zh: "zh-CN", en: "en", ja: "ja", ko: "ko", es: "es", fr: "fr", de: "de", pt: "pt", th: "th", vi: "vi", ru: "ru" };
const keys = new Set();
globalThis.__GENERATING_I18N__ = true;
const { APP_LANGUAGES, UI_COPY, createTranslator } = await import("../src/i18n.js");
for (const key of Object.keys(UI_COPY.en)) keys.add(key);
async function collectKeys(directory) {
for (const name of await readdir(directory)) {
const path = join(directory, name);
const info = await stat(path);
if (info.isDirectory()) await collectKeys(path);
else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && name !== "i18nCompletion.js") {
const source = await readFile(path, "utf8");
for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) keys.add(match[1]);
}
}
}
function chunksFor(entries, maximumLength = 3500) {
const chunks = [];
let current = [];
let length = 0;
for (const entry of entries) {
const line = `@@${entry.index}@@ ${entry.text.replace(/\s+/g, " ").trim()}`;
if (current.length && length + line.length + 1 > maximumLength) {
chunks.push(current);
current = [];
length = 0;
}
current.push({ ...entry, line });
length += line.length + 1;
}
if (current.length) chunks.push(current);
return chunks;
}
async function translateChunk(chunk, target) {
const url = new URL("https://translate.googleapis.com/translate_a/single");
url.searchParams.set("client", "gtx");
url.searchParams.set("sl", "en");
url.searchParams.set("tl", target);
url.searchParams.set("dt", "t");
url.searchParams.set("q", chunk.map(({ line }) => line).join("\n"));
const response = await fetch(url);
if (!response.ok) throw new Error(`Translation failed: ${response.status}`);
const payload = await response.json();
const translated = payload[0].map((part) => part[0]).join("");
const values = new Map();
for (const match of translated.matchAll(/@@(\d+)@@\s*([\s\S]*?)(?=\s*@@\d+@@|$)/g)) {
values.set(Number(match[1]), match[2].trim());
}
for (const entry of chunk) {
if (!values.has(entry.index)) throw new Error(`Missing translated entry K${entry.index} for ${target}`);
}
return values;
}
await collectKeys(new URL("../src", import.meta.url).pathname);
const sortedKeys = [...keys].sort();
const english = createTranslator("en");
const output = {};
for (const { id } of APP_LANGUAGES) {
const current = createTranslator(id);
const entries = sortedKeys.flatMap((key, index) => {
const englishText = english(key) === key ? RAW_KEY_ENGLISH[key] : english(key);
if (!englishText) throw new Error(`No English source text for ${key}`);
const needsCompletion = current(key) === key || (id !== "en" && current(key) === english(key));
return needsCompletion ? [{ key, index, text: englishText }] : [];
});
output[id] = {};
if (id === "en") {
for (const entry of entries) output[id][entry.key] = entry.text;
continue;
}
for (const chunk of chunksFor(entries)) {
const translated = await translateChunk(chunk, TARGET_CODES[id]);
for (const entry of chunk) output[id][entry.key] = translated.get(entry.index);
}
process.stdout.write(`${id}: ${entries.length} completed\n`);
}
const source = `// Generated by scripts/generate-i18n-completion.mjs.\n// Regenerate after adding user-visible t(\"\") keys.\nexport const I18N_COMPLETION_COPY = ${JSON.stringify(output, null, 2)};\n`;
await writeFile(new URL("../src/i18nCompletion.js", import.meta.url), source);
+115
View File
@@ -0,0 +1,115 @@
import { parse } from "@babel/parser";
import traverseModule from "@babel/traverse";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
const traverse = traverseModule.default;
const LANGUAGES = { zh: "zh-CN", en: "en", ja: "ja", ko: "ko", es: "es", fr: "fr", de: "de", pt: "pt", th: "th", vi: "vi", ru: "ru" };
const messages = new Set();
const USER_MESSAGE_CALL = /^(?:notify|commit|clear|replace|setStatus|setStatusText|reject|onProgress|confirm|alert|Error)/;
function callName(node) {
const callee = node.callee;
if (callee?.type === "Identifier") return callee.name;
if (callee?.type === "MemberExpression") return callee.property?.name ?? callee.property?.value ?? "";
return "";
}
function belongsToUserMessage(path) {
return Boolean(path.findParent((parent) => (parent.isCallExpression() || parent.isNewExpression()) && USER_MESSAGE_CALL.test(callName(parent.node))));
}
async function collectMessages(directory) {
for (const name of await readdir(directory)) {
const path = join(directory, name);
const info = await stat(path);
if (info.isDirectory()) await collectMessages(path);
else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && !/i18n|ttsText|asr\.js|workers/.test(path)) {
const source = await readFile(path, "utf8");
const ast = parse(source, { sourceType: "module", plugins: ["jsx"] });
traverse(ast, {
StringLiteral(path) {
if (/[\u3400-\u9fff]/u.test(path.node.value) && belongsToUserMessage(path)) messages.add(path.node.value.replace(/\s+/g, " ").trim());
},
TemplateLiteral(path) {
const value = path.node.quasis.map((part, index) => `${part.value.cooked}${index < path.node.expressions.length ? `{${index}}` : ""}`).join("").replace(/\s+/g, " ").trim();
if (/[\u3400-\u9fff]/u.test(value) && belongsToUserMessage(path)) messages.add(value);
},
});
}
}
}
function chunksFor(entries, maximumLength = 900) {
const chunks = [];
let current = [];
let length = 0;
for (const entry of entries) {
const line = `@@${entry.index}@@ ${entry.source}`;
if (current.length && length + line.length + 1 > maximumLength) {
chunks.push(current);
current = [];
length = 0;
}
current.push({ ...entry, line });
length += line.length + 1;
}
if (current.length) chunks.push(current);
return chunks;
}
async function translateChunk(chunk, target) {
const url = new URL("https://translate.googleapis.com/translate_a/single");
url.searchParams.set("client", "gtx");
url.searchParams.set("sl", "zh-CN");
url.searchParams.set("tl", target);
url.searchParams.set("dt", "t");
url.searchParams.set("q", chunk.map(({ line }) => line).join("\n"));
const response = await fetch(url);
if (!response.ok) throw new Error(`Translation failed: ${response.status}`);
const payload = await response.json();
const translated = payload[0].map((part) => part[0]).join("");
const values = new Map();
for (const match of translated.matchAll(/@@(\d+)@@\s*([\s\S]*?)(?=\s*@@\d+@@|$)/g)) values.set(Number(match[1]), match[2].trim());
for (const entry of chunk) if (!values.has(entry.index)) throw new Error(`Missing translated message ${entry.index} for ${target}`);
return values;
}
async function translateText(source, target) {
const url = new URL("https://translate.googleapis.com/translate_a/single");
url.searchParams.set("client", "gtx");
url.searchParams.set("sl", "zh-CN");
url.searchParams.set("tl", target);
url.searchParams.set("dt", "t");
url.searchParams.set("q", source);
const response = await fetch(url);
if (!response.ok) throw new Error(`Translation failed: ${response.status}`);
const payload = await response.json();
return payload[0].map((part) => part[0]).join("").trim();
}
await collectMessages(new URL("../src", import.meta.url).pathname);
const entries = [...messages].sort().map((source, index) => ({ index, source }));
const output = {};
for (const [language, target] of Object.entries(LANGUAGES)) {
output[language] = {};
if (language === "zh") {
for (const entry of entries) output[language][entry.source] = entry.source;
} else {
for (const chunk of chunksFor(entries)) {
const translated = await translateChunk(chunk, target);
for (const entry of chunk) output[language][entry.source] = translated.get(entry.index);
}
const templates = entries.filter(({ source }) => /\{\d+\}/.test(source));
for (let index = 0; index < templates.length; index += 6) {
const batch = templates.slice(index, index + 6);
const translated = await Promise.all(batch.map(({ source }) => translateText(source, target)));
batch.forEach((entry, itemIndex) => { output[language][entry.source] = translated[itemIndex]; });
}
}
process.stdout.write(`${language}: ${entries.length} messages\n`);
}
const source = `// Generated by scripts/generate-ui-message-copy.mjs.\nexport const UI_MESSAGE_COPY = ${JSON.stringify(output, null, 2)};\n`;
await writeFile(new URL("../src/i18nMessages.js", import.meta.url), source);
+1 -1
View File
@@ -146,7 +146,7 @@ export function App() {
voiceFilter, voiceTab,
} = useEditorUiState();
const [userAssets, setUserAssets] = useState([]);
const { notify, toast } = useToast();
const { notify, toast } = useToast(2600, uiLanguage || "zh");
const [previewVideoMediaTime, setPreviewVideoMediaTime] = useState(0);
const [visionRecords, setVisionRecords] = useState({});
const [visionJob, setVisionJob] = useState({
+1 -1
View File
@@ -1895,7 +1895,7 @@ export function Timeline({
key={`junction-${segment.id}`}
type="button"
aria-label={`${t("transition")}: ${trOption(TRANSITIONS.find((item) => item.id === transition.id)?.name || "无转场")}`}
title="设置转场"
title={t("transitionSettings")}
style={{ left: `${((range?.end || 0) / Math.max(0.01, timelineDuration)) * 100}%` }}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
+4 -4
View File
@@ -67,7 +67,7 @@ export function Topbar({
{t("fileMenu")} <CaretDown size={13} />
</button>
{showFileMenu ? (
<Popover className="project-file-popover" onClose={() => setShowFileMenu(false)}>
<Popover className="project-file-popover" closeLabel={t("close")} onClose={() => setShowFileMenu(false)}>
<div className="file-menu-card">
<div className="file-menu-heading">
<span>{t("projectMenuHeading")}</span>
@@ -127,7 +127,7 @@ export function Topbar({
{ratio.label} <CaretDown size={14} />
</button>
{showRatioMenu ? (
<Popover onClose={() => setShowRatioMenu(false)}>
<Popover closeLabel={t("close")} onClose={() => setShowRatioMenu(false)}>
<div className="menu-list">
{RATIO_OPTIONS.map((option) => (
<button
@@ -164,7 +164,7 @@ export function Topbar({
{!exporting ? <CaretDown size={13} weight="bold" /> : null}
</button>
{showExportMenu ? (
<Popover className="export-settings-popover" onClose={() => setShowExportMenu(false)}>
<Popover className="export-settings-popover" closeLabel={t("close")} onClose={() => setShowExportMenu(false)}>
<div className="export-settings-card">
<div className="export-settings-heading"><div><strong>{t("videoExport")}</strong><small>{t("videoExportHint")}</small></div><span>{exportSettings.codec === "h264" ? "MP4" : "WebM"}</span></div>
<div className="export-setting-field">
@@ -189,7 +189,7 @@ export function Topbar({
<GearSix size={19} />
</IconButton>
{showSettings ? (
<Popover onClose={() => setShowSettings(false)}>
<Popover closeLabel={t("close")} onClose={() => setShowSettings(false)}>
<div className="settings-panel">
<strong>{t("exportSettings")}</strong>
<label>
+1 -1
View File
@@ -1422,7 +1422,7 @@ export function VoiceSynthesisPanel({
{voiceFilter === "all" ? t("allVoices") : voiceFilter} <CaretDown size={14} />
</button>
{showVoiceFilter ? (
<Popover onClose={() => setShowVoiceFilter(false)}>
<Popover closeLabel={t("close")} onClose={() => setShowVoiceFilter(false)}>
<div className="menu-list">
{["all", ...voiceLanguages].map((filter) => (
<button
+2 -2
View File
@@ -17,7 +17,7 @@ export function IconButton({ label, children, active = false, disabled = false,
);
}
export function Popover({ children, onClose, className = "" }) {
export function Popover({ children, onClose, closeLabel = "Close", className = "" }) {
const popoverRef = useRef(null);
useEffect(() => {
@@ -37,7 +37,7 @@ export function Popover({ children, onClose, className = "" }) {
return (
<div ref={popoverRef} className={`popover ${className}`.trim()} role="dialog">
<button className="popover-close" type="button" aria-label="关闭" onClick={onClose}>
<button className="popover-close" type="button" aria-label={closeLabel} onClick={onClose}>
<X size={14} />
</button>
{children}
+5 -3
View File
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
export function useToast(timeout = 2600) {
import { localizeUiMessage } from "../i18nMessageRuntime.js";
export function useToast(timeout = 2600, language = "zh") {
const [toast, setToast] = useState("");
const timerRef = useRef(0);
const notify = useCallback((message) => {
setToast(message); clearTimeout(timerRef.current);
setToast(localizeUiMessage(message, language)); clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setToast(""), timeout);
}, [timeout]);
}, [language, timeout]);
useEffect(() => () => clearTimeout(timerRef.current), []);
return { notify, toast };
}
+48 -1
View File
@@ -1,3 +1,5 @@
import { I18N_COMPLETION_COPY } from "./i18nCompletion.js";
export const LANGUAGE_STORAGE_KEY = "ai-voiceover-ui-language";
export const APP_LANGUAGES = [
@@ -27,6 +29,34 @@ const EXPORT_RENDER_COPY = {
vi: { exportPreparing: "Đang chuẩn bị xuất", exportRecordingStream: "Đang ghi luồng video {format}", exportEmbeddedAudio: "Đang chuẩn bị âm thanh nhúng {current}/{total}", exportOfflinePreparing: "Đang chuẩn bị kết xuất ngoại tuyến", exportOfflineRendering: "Kết xuất ngoại tuyến {current}/{total}", exportVerifyFile: "Đang xác minh tệp xuất", exportPrepareVisuals: "Đang chuẩn bị hình ảnh", exportPrepareTracks: "Đang chuẩn bị khung vẽ và rãnh", exportMixAudio: "Đang giải mã và trộn âm thanh", exportStartRecording: "Đang bắt đầu ghi video", exportRecording: "Đang ghi luồng video", exportPackageFile: "Đang đóng gói tệp xuất", exportCompatibility: "Đang chuyển sang xuất tương thích", exportSaveFile: "Đang lưu tệp {format}", exportComplete: "Xuất hoàn tất", exportFailed: "Xuất thất bại" },
};
const PROJECT_CHROME_COPY = {
zh: { fileMenu: "文件", projectMenuHeading: "项目", newProject: "新建项目", newProjectHint: "从空白时间线开始", importProject: "导入项目包", importProjectHint: "恢复时间线及全部媒体", exportProject: "导出项目包", exportProjectHint: "打包图片、视频和音频", exportVideo: "导出视频", exportSettings: "导出设置", exportCaptions: "导出字幕", enableAudioTrack: "启用配音轨", enableSourceTrack: "启用原声音轨", enableMusicTrack: "启用背景音乐", checkModelCache: "检查模型缓存", language: "语言" },
en: { fileMenu: "File", projectMenuHeading: "Project", newProject: "New project", newProjectHint: "Start with a blank timeline", importProject: "Import project package", importProjectHint: "Restore the timeline and all media", exportProject: "Export project package", exportProjectHint: "Bundle images, video, and audio", exportVideo: "Export video", exportSettings: "Export Settings", exportCaptions: "Export captions", enableAudioTrack: "Enable voice track", enableSourceTrack: "Enable source audio", enableMusicTrack: "Enable background music", checkModelCache: "Check model cache", language: "Language" },
ja: { fileMenu: "ファイル", projectMenuHeading: "プロジェクト", newProject: "新規プロジェクト", newProjectHint: "空のタイムラインから開始", importProject: "プロジェクトを読み込む", importProjectHint: "タイムラインとすべてのメディアを復元", exportProject: "プロジェクトを書き出す", exportProjectHint: "画像・動画・音声をまとめて保存", exportVideo: "動画を書き出す", exportSettings: "書き出し設定", exportCaptions: "字幕を書き出す", enableAudioTrack: "ナレーションを有効化", enableSourceTrack: "元音声を有効化", enableMusicTrack: "BGMを有効化", checkModelCache: "モデルキャッシュを確認", language: "言語" },
ko: { fileMenu: "파일", projectMenuHeading: "프로젝트", newProject: "새 프로젝트", newProjectHint: "빈 타임라인에서 시작", importProject: "프로젝트 패키지 가져오기", importProjectHint: "타임라인과 모든 미디어 복원", exportProject: "프로젝트 패키지 내보내기", exportProjectHint: "이미지, 비디오 및 오디오 묶기", exportVideo: "비디오 내보내기", exportSettings: "내보내기 설정", exportCaptions: "자막 내보내기", enableAudioTrack: "보이스오버 트랙 사용", enableSourceTrack: "원본 오디오 사용", enableMusicTrack: "배경 음악 사용", checkModelCache: "모델 캐시 확인", language: "언어" },
es: { fileMenu: "Archivo", projectMenuHeading: "Proyecto", newProject: "Nuevo proyecto", newProjectHint: "Empezar con una línea de tiempo vacía", importProject: "Importar paquete del proyecto", importProjectHint: "Restaurar la línea de tiempo y todos los medios", exportProject: "Exportar paquete del proyecto", exportProjectHint: "Empaquetar imágenes, vídeo y audio", exportVideo: "Exportar vídeo", exportSettings: "Ajustes de exportación", exportCaptions: "Exportar subtítulos", enableAudioTrack: "Activar pista de voz", enableSourceTrack: "Activar audio original", enableMusicTrack: "Activar música de fondo", checkModelCache: "Comprobar caché de modelos", language: "Idioma" },
fr: { fileMenu: "Fichier", projectMenuHeading: "Projet", newProject: "Nouveau projet", newProjectHint: "Commencer avec une timeline vide", importProject: "Importer le projet", importProjectHint: "Restaurer la timeline et tous les médias", exportProject: "Exporter le projet", exportProjectHint: "Regrouper images, vidéos et audio", exportVideo: "Exporter la vidéo", exportSettings: "Paramètres dexport", exportCaptions: "Exporter les sous-titres", enableAudioTrack: "Activer la piste voix", enableSourceTrack: "Activer laudio source", enableMusicTrack: "Activer la musique de fond", checkModelCache: "Vérifier le cache des modèles", language: "Langue" },
de: { fileMenu: "Datei", projectMenuHeading: "Projekt", newProject: "Neues Projekt", newProjectHint: "Mit einer leeren Zeitleiste beginnen", importProject: "Projektpaket importieren", importProjectHint: "Zeitleiste und alle Medien wiederherstellen", exportProject: "Projektpaket exportieren", exportProjectHint: "Bilder, Videos und Audio bündeln", exportVideo: "Video exportieren", exportSettings: "Exporteinstellungen", exportCaptions: "Untertitel exportieren", enableAudioTrack: "Sprachspur aktivieren", enableSourceTrack: "Originalton aktivieren", enableMusicTrack: "Hintergrundmusik aktivieren", checkModelCache: "Modell-Cache prüfen", language: "Sprache" },
pt: { fileMenu: "Arquivo", projectMenuHeading: "Projeto", newProject: "Novo projeto", newProjectHint: "Começar com uma linha do tempo vazia", importProject: "Importar pacote do projeto", importProjectHint: "Restaurar a linha do tempo e todas as mídias", exportProject: "Exportar pacote do projeto", exportProjectHint: "Agrupar imagens, vídeos e áudio", exportVideo: "Exportar vídeo", exportSettings: "Configurações de exportação", exportCaptions: "Exportar legendas", enableAudioTrack: "Ativar faixa de narração", enableSourceTrack: "Ativar áudio original", enableMusicTrack: "Ativar música de fundo", checkModelCache: "Verificar cache dos modelos", language: "Idioma" },
th: { fileMenu: "ไฟล์", projectMenuHeading: "โปรเจกต์", newProject: "โปรเจกต์ใหม่", newProjectHint: "เริ่มจากไทม์ไลน์ว่าง", importProject: "นำเข้าแพ็กเกจโปรเจกต์", importProjectHint: "กู้คืนไทม์ไลน์และสื่อทั้งหมด", exportProject: "ส่งออกแพ็กเกจโปรเจกต์", exportProjectHint: "รวมรูปภาพ วิดีโอ และเสียง", exportVideo: "ส่งออกวิดีโอ", exportSettings: "การตั้งค่าการส่งออก", exportCaptions: "ส่งออกคำบรรยาย", enableAudioTrack: "เปิดใช้แทร็กเสียงพากย์", enableSourceTrack: "เปิดใช้เสียงต้นฉบับ", enableMusicTrack: "เปิดใช้เพลงพื้นหลัง", checkModelCache: "ตรวจสอบแคชโมเดล", language: "ภาษา" },
vi: { fileMenu: "Tệp", projectMenuHeading: "Dự án", newProject: "Dự án mới", newProjectHint: "Bắt đầu với dòng thời gian trống", importProject: "Nhập gói dự án", importProjectHint: "Khôi phục dòng thời gian và toàn bộ nội dung", exportProject: "Xuất gói dự án", exportProjectHint: "Đóng gói hình ảnh, video và âm thanh", exportVideo: "Xuất video", exportSettings: "Cài đặt xuất", exportCaptions: "Xuất phụ đề", enableAudioTrack: "Bật rãnh lồng tiếng", enableSourceTrack: "Bật âm thanh gốc", enableMusicTrack: "Bật nhạc nền", checkModelCache: "Kiểm tra bộ nhớ đệm mô hình", language: "Ngôn ngữ" },
ru: { fileMenu: "Файл", projectMenuHeading: "Проект", newProject: "Новый проект", newProjectHint: "Начать с пустой временной шкалы", importProject: "Импортировать пакет проекта", importProjectHint: "Восстановить временную шкалу и все медиа", exportProject: "Экспортировать пакет проекта", exportProjectHint: "Упаковать изображения, видео и аудио", exportVideo: "Экспортировать видео", exportSettings: "Настройки экспорта", exportCaptions: "Экспортировать субтитры", enableAudioTrack: "Включить дорожку озвучки", enableSourceTrack: "Включить исходный звук", enableMusicTrack: "Включить фоновую музыку", checkModelCache: "Проверить кэш моделей", language: "Язык" },
};
const CORE_LABEL_COPY = {
zh: { smart: "智能", fit: "适应", visualSelectClip: "请在画面轨道中选择一个片段" },
en: { smart: "Smart", fit: "Fit", visualSelectClip: "Select a clip on the Visuals track" },
ja: { smart: "スマート", fit: "全体表示", visualSelectClip: "映像トラックのクリップを選択してください" },
ko: { smart: "스마트", fit: "화면 맞춤", visualSelectClip: "화면 트랙에서 클립을 선택하세요" },
es: { smart: "Inteligente", fit: "Ajustar", visualSelectClip: "Selecciona un clip en la pista de imagen" },
fr: { smart: "Intelligent", fit: "Ajuster", visualSelectClip: "Sélectionnez un clip dans la piste visuelle" },
de: { smart: "Intelligent", fit: "Einpassen", visualSelectClip: "Clip in der Bildspur auswählen" },
pt: { smart: "Inteligente", fit: "Ajustar", visualSelectClip: "Selecione um clipe na faixa visual" },
th: { smart: "อัจฉริยะ", fit: "พอดี", visualSelectClip: "เลือกคลิปในแทร็กภาพ" },
vi: { smart: "Thông minh", fit: "Vừa khung", visualSelectClip: "Chọn một clip trong rãnh hình ảnh" },
ru: { smart: "Умные", fit: "Вписать", visualSelectClip: "Выберите клип на видеодорожке" },
};
const MOBILE_DRAWER_COPY = {
zh: { mobilePanelView: "抽屉视图", mobileDrawerTools: "工具", properties: "属性", mobileAddMedia: "添加素材", mobileAssetActions: "素材添加操作", mobileAssetSelected: "已选择素材", mobileAssetChooseDestination: "素材已选中,请选择添加位置", mobileAddToMainTrack: "添加到主轨道", mobileAddToVoice: "添加到配音轨", mobileAddToMusic: "添加到音乐轨" },
en: { mobilePanelView: "Drawer view", mobileDrawerTools: "Tools", properties: "Properties", mobileAddMedia: "Add media", mobileAssetActions: "Add media", mobileAssetSelected: "Selected media", mobileAssetChooseDestination: "Media selected. Choose where to add it.", mobileAddToMainTrack: "Add to main track", mobileAddToVoice: "Add to voiceover", mobileAddToMusic: "Add to music" },
@@ -1896,7 +1926,24 @@ export function createTranslator(languageId) {
const mobileDrawerCopy = MOBILE_DRAWER_COPY[languageId] ?? MOBILE_DRAWER_COPY.en;
const mobileClipActionCopy = MOBILE_CLIP_ACTION_COPY[languageId] ?? MOBILE_CLIP_ACTION_COPY.en;
const mobileStickerCopy = MOBILE_STICKER_COPY[languageId] ?? MOBILE_STICKER_COPY.en;
return (key, fallbackText) => captionAudioLinkCopy[key] ?? CAPTION_AUDIO_LINK_COPY.en[key] ?? ttsBackendCopy[key] ?? TTS_BACKEND_COPY.en[key] ?? mobileStickerCopy[key] ?? MOBILE_STICKER_COPY.en[key] ?? mobileClipActionCopy[key] ?? MOBILE_CLIP_ACTION_COPY.en[key] ?? mobileDrawerCopy[key] ?? MOBILE_DRAWER_COPY.en[key] ?? srtImportCopy[key] ?? exportCopy[key] ?? EXPORT_RENDER_COPY.en[key] ?? assetPreviewCopy[key] ?? ASSET_PREVIEW_COPY.en[key] ?? assetDropCopy[key] ?? ASSET_DROP_COPY.en[key] ?? autoCaptionStatusCopy[key] ?? AUTO_CAPTION_STATUS_COPY.en[key] ?? copy[key] ?? fallback[key] ?? UI_COPY.zh[key] ?? fallbackText ?? key;
const completionCopy = globalThis.__GENERATING_I18N__ ? {} : I18N_COMPLETION_COPY[languageId] ?? I18N_COMPLETION_COPY.en ?? {};
const projectChromeCopy = PROJECT_CHROME_COPY[languageId] ?? PROJECT_CHROME_COPY.en;
const coreLabelCopy = CORE_LABEL_COPY[languageId] ?? CORE_LABEL_COPY.en;
const specializedCopy = Object.assign({}, ...[
EXPORT_RENDER_COPY, PROJECT_CHROME_COPY, CORE_LABEL_COPY, MOBILE_DRAWER_COPY, MOBILE_CLIP_ACTION_COPY,
VISUAL_EDITOR_COPY, TRANSITION_EDITOR_COPY, ASSET_PREVIEW_COPY, ASSET_DROP_COPY,
AUTO_CAPTION_STATUS_COPY, VISUAL_PANEL_TITLE_COPY, VISUAL_MASK_SHAPE_COPY,
VISUAL_KEYFRAME_ACTION_COPY, VISUAL_TAB_COPY, SOURCE_AUDIO_SYNC_COPY,
CAPTION_WORKSPACE_COPY, RESOURCE_LINK_COPY, VISUAL_AI_TAB_COPY, REMASTER_COPY,
REMASTER_CLIP_COPY, REMASTER_GPU_COPY, REMASTER_PHASE_COPY, CONTEXT_PANEL_COPY,
TIMELINE_AUDIO_MENU_COPY, TTS_BACKEND_COPY, CAPTION_AUDIO_LINK_COPY,
VISUAL_ANIMATION_COPY, STICKER_EDITOR_COPY, MOBILE_STICKER_COPY,
CAPTION_DEFAULT_COPY, SMART_WORKSPACE_COPY, AUTO_EDIT_COPY, AUTO_EDIT_BUTTON_COPY,
AUTO_EDIT_REVIEW_COPY, AUTO_EDIT_FLOW_COPY, AUTO_EDIT_SEGMENT_COPY,
AUTO_EDIT_RESULT_COPY, IMAGE_AI_CAPTION_COPY, PICTURE_IN_PICTURE_COPY,
SRT_IMPORT_COPY,
].map((source) => source[languageId] ?? {}));
return (key, fallbackText) => coreLabelCopy[key] ?? specializedCopy[key] ?? projectChromeCopy[key] ?? PROJECT_CHROME_COPY.en[key] ?? captionAudioLinkCopy[key] ?? CAPTION_AUDIO_LINK_COPY.en[key] ?? ttsBackendCopy[key] ?? TTS_BACKEND_COPY.en[key] ?? mobileStickerCopy[key] ?? MOBILE_STICKER_COPY.en[key] ?? mobileClipActionCopy[key] ?? MOBILE_CLIP_ACTION_COPY.en[key] ?? mobileDrawerCopy[key] ?? MOBILE_DRAWER_COPY.en[key] ?? srtImportCopy[key] ?? exportCopy[key] ?? EXPORT_RENDER_COPY.en[key] ?? assetPreviewCopy[key] ?? ASSET_PREVIEW_COPY.en[key] ?? assetDropCopy[key] ?? ASSET_DROP_COPY.en[key] ?? autoCaptionStatusCopy[key] ?? AUTO_CAPTION_STATUS_COPY.en[key] ?? completionCopy[key] ?? copy[key] ?? fallback[key] ?? UI_COPY.zh[key] ?? fallbackText ?? key;
}
export function translateOptionName(languageId, name) {
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { APP_LANGUAGES, UI_COPY, createTranslator } from "./i18n.js";
import { I18N_COMPLETION_COPY } from "./i18nCompletion.js";
function collectRuntimeTranslationKeys(directory, keys = new Set()) {
for (const name of readdirSync(directory)) {
const path = join(directory, name);
const info = statSync(path);
if (info.isDirectory()) collectRuntimeTranslationKeys(path, keys);
else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && name !== "i18nCompletion.js") {
const source = readFileSync(path, "utf8");
for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) keys.add(match[1]);
}
}
return keys;
}
describe("complete editor localization", () => {
const runtimeKeys = collectRuntimeTranslationKeys(new URL(".", import.meta.url).pathname);
const editorKeys = new Set([...Object.keys(UI_COPY.en), ...runtimeKeys]);
it("resolves every static and dynamic editor key in every supported language", () => {
for (const { id } of APP_LANGUAGES) {
const t = createTranslator(id);
for (const key of editorKeys) expect(t(key), `${id}.${key}`).not.toBe(key);
}
});
it("records every remaining English-equivalent fallback as an explicit locale entry", () => {
const english = createTranslator("en");
for (const { id } of APP_LANGUAGES.filter(({ id }) => id !== "en")) {
const t = createTranslator(id);
for (const key of editorKeys) {
if (t(key) === english(key)) {
expect(Object.hasOwn(I18N_COMPLETION_COPY[id] ?? {}, key), `${id}.${key}`).toBe(true);
}
}
}
});
});
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { UI_MESSAGE_COPY } from "./i18nMessages.js";
const templateCache = new Map();
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileTemplates(language) {
if (templateCache.has(language)) return templateCache.get(language);
const entries = Object.entries(UI_MESSAGE_COPY[language] ?? UI_MESSAGE_COPY.en ?? {})
.filter(([source]) => /\{\d+\}/.test(source))
.map(([source, translated]) => {
const placeholders = [...source.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
const pattern = source.split(/\{\d+\}/g).map(escapeRegex).join("(.*?)");
return { literalLength: source.replace(/\{\d+\}/g, "").length, placeholders, regex: new RegExp(`^${pattern}$`), translated };
})
.sort((left, right) => right.literalLength - left.literalLength);
templateCache.set(language, entries);
return entries;
}
export function localizeUiMessage(message, language) {
const text = String(message ?? "");
const copy = UI_MESSAGE_COPY[language] ?? UI_MESSAGE_COPY.en ?? {};
if (copy[text]) return copy[text];
for (const entry of compileTemplates(language)) {
const match = text.match(entry.regex);
if (!match) continue;
let result = entry.translated;
entry.placeholders.forEach((placeholder, index) => {
result = result.replaceAll(`{${placeholder}}`, match[index + 1] ?? "");
});
return result;
}
return text;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { localizeUiMessage } from "./i18nMessageRuntime.js";
describe("localized legacy UI messages", () => {
it("translates exact hard-coded notifications", () => {
const translated = localizeUiMessage("没有可撤销的编辑操作", "ko");
expect(translated).not.toBe("没有可撤销的编辑操作");
expect(translated).toMatch(/[가-힣]/);
});
it("translates templated notifications and preserves their values", () => {
expect(localizeUiMessage("画布比例已切换为 9:16", "en")).toBe("Canvas scale switched to 9:16");
expect(localizeUiMessage("画布比例已切换为 9:16", "vi")).toContain("9:16");
});
it("leaves unknown external errors intact", () => {
expect(localizeUiMessage("HTTP 503", "fr")).toBe("HTTP 503");
});
});
+2951
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
import { parse } from "@babel/parser";
import traverseModule from "@babel/traverse";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { APP_LANGUAGES } from "./i18n.js";
import { UI_MESSAGE_COPY } from "./i18nMessages.js";
const traverse = traverseModule.default?.default ?? traverseModule.default ?? traverseModule;
const USER_MESSAGE_CALL = /^(?:notify|commit|clear|replace|setStatus|setStatusText|reject|onProgress|confirm|alert|Error)/;
function callName(node) {
const callee = node.callee;
if (callee?.type === "Identifier") return callee.name;
if (callee?.type === "MemberExpression") return callee.property?.name ?? callee.property?.value ?? "";
return "";
}
function belongsToUserMessage(path) {
return Boolean(path.findParent((parent) => (parent.isCallExpression() || parent.isNewExpression()) && USER_MESSAGE_CALL.test(callName(parent.node))));
}
function collectLegacyMessages(directory, messages = new Set()) {
for (const name of readdirSync(directory)) {
const path = join(directory, name);
const info = statSync(path);
if (info.isDirectory()) collectLegacyMessages(path, messages);
else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && !/i18n|ttsText|asr\.js|workers/.test(path)) {
const ast = parse(readFileSync(path, "utf8"), { sourceType: "module", plugins: ["jsx"] });
traverse(ast, {
StringLiteral(path) {
if (/[\u3400-\u9fff]/u.test(path.node.value) && belongsToUserMessage(path)) messages.add(path.node.value.replace(/\s+/g, " ").trim());
},
TemplateLiteral(path) {
const value = path.node.quasis.map((part, index) => `${part.value.cooked}${index < path.node.expressions.length ? `{${index}}` : ""}`).join("").replace(/\s+/g, " ").trim();
if (/[\u3400-\u9fff]/u.test(value) && belongsToUserMessage(path)) messages.add(value);
},
});
}
}
return messages;
}
describe("legacy user-visible message localization", () => {
it("keeps every hard-coded Chinese message in every locale catalog", () => {
const messages = collectLegacyMessages(new URL(".", import.meta.url).pathname);
for (const { id } of APP_LANGUAGES) {
for (const message of messages) {
expect(Object.hasOwn(UI_MESSAGE_COPY[id] ?? {}, message), `${id}: ${message}`).toBe(true);
}
}
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { APP_LANGUAGES, createTranslator } from "./i18n.js";
const keys = [
"fileMenu", "projectMenuHeading", "newProject", "newProjectHint",
"importProject", "importProjectHint", "exportProject", "exportProjectHint",
"exportVideo", "exportSettings", "exportCaptions", "enableAudioTrack",
"enableSourceTrack", "enableMusicTrack", "checkModelCache", "language",
];
describe("project chrome translations", () => {
it("provides every project and export setting label in every supported language", () => {
for (const { id } of APP_LANGUAGES) {
const t = createTranslator(id);
for (const key of keys) expect(t(key), `${id}.${key}`).not.toBe(key);
}
});
it("does not fall back to English for localized interfaces", () => {
const english = createTranslator("en");
for (const { id } of APP_LANGUAGES.filter(({ id }) => !["en"].includes(id))) {
const t = createTranslator(id);
for (const key of keys) expect(t(key), `${id}.${key}`).not.toBe(english(key));
}
});
});
+1 -1
View File
@@ -184,7 +184,7 @@ button:disabled {
}
.file-menu-copy { display: grid; gap: 3px; min-width: 0; }
.file-menu-copy strong { color: #eaf4f7; font-size: 13px; font-weight: 650; }
.file-menu-copy small { color: #84929d; font-size: 11px; white-space: nowrap; }
.file-menu-copy small { color: #84929d; font-size: 11px; line-height: 1.35; white-space: normal; }
.file-menu-format {
align-self: start;
margin-top: 3px;