complete editor internationalization (#43)
Co-authored-by: haixin.yang <haixin.yang@weimob.com>
This commit is contained in:
committed by
GitHub
co-authored by
haixin.yang
parent
11ddf231e0
commit
c02e6136a1
@@ -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);
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 d’export", exportCaptions: "Exporter les sous-titres", enableAudioTrack: "Activer la piste voix", enableSourceTrack: "Activer l’audio 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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user