From a3322e801c381816cc322b435ec56616eca04833 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:25:58 +0000 Subject: [PATCH 1/4] fix(i18n): split locale catalogs and address review feedback Move EN/zh-CN strings into dedicated message modules, derive the runtime message map from the English catalog, restore browse CTA styling and singular word counts, and wire emitters to catalog strings to prevent drift. Co-authored-by: Wassim Gharbi --- components/Editor.tsx | 14 +- components/ExportDialog.tsx | 5 +- components/TranscriptPanel.tsx | 7 +- components/UploadScreen.tsx | 7 +- electron/locale.ts | 108 ------- electron/locale/en.ts | 43 +++ electron/locale/index.ts | 26 ++ electron/locale/zh-CN.ts | 43 +++ hooks/useTranscriber.ts | 9 +- lib/ffmpeg.ts | 11 +- lib/i18n.ts | 542 -------------------------------- lib/i18n/index.ts | 121 +++++++ lib/i18n/messages/en.ts | 207 ++++++++++++ lib/i18n/messages/zh-CN.ts | 206 ++++++++++++ lib/i18n/runtimeMessages.ts | 54 ++++ lib/parseTranscript.ts | 11 +- lib/projects.ts | 16 - lib/serializeTranscript.ts | 5 +- lib/store.ts | 9 +- tests/i18n-test.ts | 50 ++- tests/projects-test.ts | 11 +- workers/transcription.worker.ts | 42 ++- 22 files changed, 817 insertions(+), 730 deletions(-) delete mode 100644 electron/locale.ts create mode 100644 electron/locale/en.ts create mode 100644 electron/locale/index.ts create mode 100644 electron/locale/zh-CN.ts delete mode 100644 lib/i18n.ts create mode 100644 lib/i18n/index.ts create mode 100644 lib/i18n/messages/en.ts create mode 100644 lib/i18n/messages/zh-CN.ts create mode 100644 lib/i18n/runtimeMessages.ts diff --git a/components/Editor.tsx b/components/Editor.tsx index 0288eb4..76c961e 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -33,6 +33,7 @@ import ModelSelector, { import ImportTranscriptOption from "./ImportTranscriptOption"; import { MODEL_ORDER } from "@/lib/models"; import { isTypingTarget } from "@/lib/keyboard"; +import { en } from "@/lib/i18n/messages/en"; import { useI18n } from "./I18nProvider"; /** How long the desktop mode-change overlay stays up. Matches the macOS @@ -216,9 +217,9 @@ export default function Editor() { (async () => { const s = useEditorStore.getState(); try { - s.setProgress({ message: "Loading media engine…", value: null }); + s.setProgress({ message: en["progress.loadingMediaEngine"], value: null }); await getFFmpeg(); - s.setProgress({ message: "Extracting audio…", value: null }); + s.setProgress({ message: en["progress.extractingAudio"], value: null }); const audio = await extractAudio(videoFile); s.setAudio(audio); // ffmpeg's gigabyte is pure overhead from here until the user exports, @@ -237,14 +238,13 @@ export default function Editor() { // pulling the media engine is the user's network, not a bug, and // "Failed to fetch" tells them nothing about what to do next. if (isNetworkError(err)) { - s.setError( - "Couldn't load the media engine — the connection dropped. " + - "Check your internet and try again." - ); + s.setError(en["error.mediaEngineNetwork"]); return; } reportError(err, "media-pipeline"); - s.setError(err instanceof Error ? err.message : "Failed to process this file."); + s.setError( + err instanceof Error ? err.message : en["error.processFile"] + ); } })(); }, [videoFile, skipTranscription, transcribe]); diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 0f66f1d..6969706 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -27,6 +27,7 @@ import { import { useCutRanges } from "@/hooks/useCutRanges"; import { useI18n } from "./I18nProvider"; import { localizeRuntimeMessage } from "@/lib/i18n"; +import { en } from "@/lib/i18n/messages/en"; type ExportTab = "video" | "audio" | "transcript" | "subtitles"; @@ -200,7 +201,7 @@ export default function ExportDialog() { ...(activeTab === "audio" ? {} : { resolution }), }); } catch (err) { - setError(err instanceof Error ? err.message : "Export failed."); + setError(err instanceof Error ? err.message : en["error.export"]); } finally { setStatus("ready"); } @@ -232,7 +233,7 @@ export default function ExportDialog() { setError(null); trackEvent("export_completed", { kind, format }); } catch (err) { - setError(err instanceof Error ? err.message : "Export failed."); + setError(err instanceof Error ? err.message : en["error.export"]); } }, [ diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index dfcbe2f..adc85c7 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -352,7 +352,12 @@ export default function TranscriptPanel() {
{deletedCount > 0 && ( - {t("transcript.wordsDeleted", { count: deletedCount })} + {t( + deletedCount === 1 + ? "transcript.wordDeleted" + : "transcript.wordsDeleted", + { count: deletedCount } + )} )} {status === "ready" && } diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 1122a90..e3b6292 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -364,9 +364,10 @@ export default function UploadScreen({ ) : ready ? ( <>

- {t("upload.dropOrBrowse", { - browse: t("upload.browse"), - })} + {t("upload.dropPrefix")}{" "} + + {t("upload.browse")} +

{source === "import" diff --git a/electron/locale.ts b/electron/locale.ts deleted file mode 100644 index 10b3122..0000000 --- a/electron/locale.ts +++ /dev/null @@ -1,108 +0,0 @@ -export type DesktopLocale = "en" | "zh-CN"; - -let currentLocale: DesktopLocale = "en"; - -const en = { - file: "File", - openProject: "Open Project…", - reopenLast: "Reopen Last Project", - recentProjects: "Recent Projects", - noRecent: "No Recent Projects", - clearRecent: "Clear Recent Projects", - edit: "Edit", - undo: "Undo", - redo: "Redo", - cut: "Cut", - copy: "Copy", - paste: "Paste", - pasteMatch: "Paste and Match Style", - delete: "Delete", - selectAll: "Select All", - view: "View", - reload: "Reload", - forceReload: "Force Reload", - devTools: "Toggle Developer Tools", - resetZoom: "Actual Size", - zoomIn: "Zoom In", - zoomOut: "Zoom Out", - fullscreen: "Toggle Full Screen", - window: "Window", - minimize: "Minimize", - zoom: "Zoom", - front: "Bring All to Front", - close: "Close Window", - quit: "Quit Rescript", - about: "About Rescript", - services: "Services", - hide: "Hide Rescript", - hideOthers: "Hide Others", - unhide: "Show All", - restart: "Restart", - later: "Later", - updateTitle: "Update available", - updateMessage: "Rescript {version} is ready to install.", - updateDetail: "Restart now to apply the update.", -} as const; - -type DesktopMessageKey = keyof typeof en; - -const zhCN: Record = { - file: "文件", - openProject: "打开项目…", - reopenLast: "重新打开上一个项目", - recentProjects: "最近项目", - noRecent: "没有最近项目", - clearRecent: "清除最近项目", - edit: "编辑", - undo: "撤销", - redo: "重做", - cut: "剪切", - copy: "复制", - paste: "粘贴", - pasteMatch: "粘贴并匹配样式", - delete: "删除", - selectAll: "全选", - view: "视图", - reload: "重新加载", - forceReload: "强制重新加载", - devTools: "切换开发者工具", - resetZoom: "实际大小", - zoomIn: "放大", - zoomOut: "缩小", - fullscreen: "切换全屏", - window: "窗口", - minimize: "最小化", - zoom: "缩放", - front: "前置全部窗口", - close: "关闭窗口", - quit: "退出 Rescript", - about: "关于 Rescript", - services: "服务", - hide: "隐藏 Rescript", - hideOthers: "隐藏其他应用", - unhide: "全部显示", - restart: "立即重启", - later: "稍后", - updateTitle: "有可用更新", - updateMessage: "Rescript {version} 已准备好安装。", - updateDetail: "立即重启以应用更新。", -}; - -export function resolveDesktopLocale(value: string): DesktopLocale { - const locale = value.toLowerCase(); - return locale === "zh" || locale.startsWith("zh-") ? "zh-CN" : "en"; -} - -export function setDesktopLocale(locale: DesktopLocale): void { - currentLocale = locale; -} - -export function desktopText( - key: DesktopMessageKey, - params: Record = {} -): string { - const template = (currentLocale === "zh-CN" ? zhCN : en)[key]; - return template.replace(/\{(\w+)\}/g, (token, name: string) => - Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token - ); -} diff --git a/electron/locale/en.ts b/electron/locale/en.ts new file mode 100644 index 0000000..6b77ca6 --- /dev/null +++ b/electron/locale/en.ts @@ -0,0 +1,43 @@ +export const en = { + file: "File", + openProject: "Open Project…", + reopenLast: "Reopen Last Project", + recentProjects: "Recent Projects", + noRecent: "No Recent Projects", + clearRecent: "Clear Recent Projects", + edit: "Edit", + undo: "Undo", + redo: "Redo", + cut: "Cut", + copy: "Copy", + paste: "Paste", + pasteMatch: "Paste and Match Style", + delete: "Delete", + selectAll: "Select All", + view: "View", + reload: "Reload", + forceReload: "Force Reload", + devTools: "Toggle Developer Tools", + resetZoom: "Actual Size", + zoomIn: "Zoom In", + zoomOut: "Zoom Out", + fullscreen: "Toggle Full Screen", + window: "Window", + minimize: "Minimize", + zoom: "Zoom", + front: "Bring All to Front", + close: "Close Window", + quit: "Quit Rescript", + about: "About Rescript", + services: "Services", + hide: "Hide Rescript", + hideOthers: "Hide Others", + unhide: "Show All", + restart: "Restart", + later: "Later", + updateTitle: "Update available", + updateMessage: "Rescript {version} is ready to install.", + updateDetail: "Restart now to apply the update.", +} as const; + +export type DesktopMessageKey = keyof typeof en; diff --git a/electron/locale/index.ts b/electron/locale/index.ts new file mode 100644 index 0000000..b0721cb --- /dev/null +++ b/electron/locale/index.ts @@ -0,0 +1,26 @@ +import { en, type DesktopMessageKey } from "./en"; +import { zhCN } from "./zh-CN"; + +export type DesktopLocale = "en" | "zh-CN"; + +let currentLocale: DesktopLocale = "en"; + +export function resolveDesktopLocale(value: string): DesktopLocale { + const locale = value.toLowerCase(); + // Any zh* tag maps to Simplified Chinese until Traditional UI lands. + return locale === "zh" || locale.startsWith("zh-") ? "zh-CN" : "en"; +} + +export function setDesktopLocale(locale: DesktopLocale): void { + currentLocale = locale; +} + +export function desktopText( + key: DesktopMessageKey, + params: Record = {} +): string { + const template = (currentLocale === "zh-CN" ? zhCN : en)[key]; + return template.replace(/\{(\w+)\}/g, (token, name: string) => + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token + ); +} diff --git a/electron/locale/zh-CN.ts b/electron/locale/zh-CN.ts new file mode 100644 index 0000000..a50dd18 --- /dev/null +++ b/electron/locale/zh-CN.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const zhCN: Record = { + file: "文件", + openProject: "打开项目…", + reopenLast: "重新打开上一个项目", + recentProjects: "最近项目", + noRecent: "没有最近项目", + clearRecent: "清除最近项目", + edit: "编辑", + undo: "撤销", + redo: "重做", + cut: "剪切", + copy: "复制", + paste: "粘贴", + pasteMatch: "粘贴并匹配样式", + delete: "删除", + selectAll: "全选", + view: "视图", + reload: "重新加载", + forceReload: "强制重新加载", + devTools: "切换开发者工具", + resetZoom: "实际大小", + zoomIn: "放大", + zoomOut: "缩小", + fullscreen: "切换全屏", + window: "窗口", + minimize: "最小化", + zoom: "缩放", + front: "前置全部窗口", + close: "关闭窗口", + quit: "退出 Rescript", + about: "关于 Rescript", + services: "服务", + hide: "隐藏 Rescript", + hideOthers: "隐藏其他应用", + unhide: "全部显示", + restart: "立即重启", + later: "稍后", + updateTitle: "有可用更新", + updateMessage: "Rescript {version} 已准备好安装。", + updateDetail: "立即重启以应用更新。", +}; diff --git a/hooks/useTranscriber.ts b/hooks/useTranscriber.ts index a52fd14..2c9d18d 100644 --- a/hooks/useTranscriber.ts +++ b/hooks/useTranscriber.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef } from "react"; +import { en } from "@/lib/i18n/messages/en"; import { isModelId } from "@/lib/models"; import { reportError } from "@/lib/sentry"; import { useEditorStore } from "@/lib/store"; @@ -29,13 +30,13 @@ export function useTranscriber() { const transcribe = useCallback((audio: Float32Array, duration: number) => { const store = useEditorStore.getState(); if (!isModelId(store.source)) { - store.setError("Select a speech model to transcribe."); + store.setError(en["error.selectModel"]); return; } const model = store.source; const transcriptLanguage = store.transcriptLanguage; store.setStatus("transcribing"); - store.setProgress({ message: "Loading speech model…", value: null }); + store.setProgress({ message: en["progress.loadingSpeechModel"], value: null }); // Always start a fresh worker so a prior cancel can't leave us without one. cancelTranscription(); @@ -83,9 +84,9 @@ export function useTranscriber() { workerRef.current.onerror = (err) => { const s = useEditorStore.getState(); if (s.skipTranscription) return; - s.setError(err.message || "Transcription worker crashed."); + s.setError(err.message || en["error.workerCrashed"]); reportError( - new Error(err.message || "Transcription worker crashed."), + new Error(err.message || en["error.workerCrashed"]), "transcription-worker" ); }; diff --git a/lib/ffmpeg.ts b/lib/ffmpeg.ts index 3299f03..6f9be05 100644 --- a/lib/ffmpeg.ts +++ b/lib/ffmpeg.ts @@ -1,6 +1,7 @@ "use client"; import type { FFmpeg } from "@ffmpeg/ffmpeg"; +import { en } from "@/lib/i18n/messages/en"; import type { TimeRange } from "./types"; const CORE_BASE = "/vendor/ffmpeg"; @@ -108,7 +109,7 @@ export async function extractAudio(file: File): Promise { } if (code !== 0) { if (!sawAudioStream) return null; - throw new Error("Could not extract audio from this file."); + throw new Error(en["error.extractAudio"]); } const data = (await ffmpeg.readFile(out)) as Uint8Array; await ffmpeg.deleteFile(out); @@ -174,7 +175,7 @@ export async function exportVideo( }: VideoExportOptions = {} ): Promise { if (keepRanges.length === 0) { - throw new Error("Everything has been deleted — nothing to export."); + throw new Error(en["error.nothingToExport"]); } const ffmpeg = await getFFmpeg(); const input = await ensureInput(ffmpeg, file); @@ -236,7 +237,7 @@ export async function exportVideo( ...codecArgs, "-y", out, ]); - if (code !== 0) throw new Error("Export failed while rendering the video."); + if (code !== 0) throw new Error(en["error.videoExport"]); const data = (await ffmpeg.readFile(out)) as Uint8Array; await ffmpeg.deleteFile(out); const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); @@ -260,7 +261,7 @@ export async function exportAudio( { format = "m4a" }: AudioExportOptions = {} ): Promise { if (keepRanges.length === 0) { - throw new Error("Everything has been deleted — nothing to export."); + throw new Error(en["error.nothingToExport"]); } const ffmpeg = await getFFmpeg(); const input = await ensureInput(ffmpeg, file); @@ -299,7 +300,7 @@ export async function exportAudio( ...codecArgs, "-y", out, ]); - if (code !== 0) throw new Error("Export failed while rendering the audio."); + if (code !== 0) throw new Error(en["error.audioExport"]); const data = (await ffmpeg.readFile(out)) as Uint8Array; await ffmpeg.deleteFile(out); const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); diff --git a/lib/i18n.ts b/lib/i18n.ts deleted file mode 100644 index 0f32123..0000000 --- a/lib/i18n.ts +++ /dev/null @@ -1,542 +0,0 @@ -export type UiLocale = "en" | "zh-CN"; -export type UiLocalePreference = "system" | UiLocale; - -export const DEFAULT_UI_LOCALE_PREFERENCE: UiLocalePreference = "system"; -export const UI_LOCALE_STORAGE_KEY = "rescript.ui-locale"; - -const en = { - "app.title": "Rescript — edit videos like you edit text", - "common.cancel": "Cancel", - "common.close": "Close", - "common.delete": "Delete", - "common.download": "Download", - "common.import": "Import", - "common.loading": "Loading", - "common.remove": "Remove", - "common.restore": "Restore", - "common.retry": "Try again", - "common.searchOrCreate": "Search or create…", - "common.settings": "Settings", - "common.system": "System", - "common.tools": "Tools", - "settings.appearance": "Appearance", - "settings.light": "Light", - "settings.dark": "Dark", - "settings.interfaceLanguage": "Interface language", - "settings.privacy": "Privacy", - "settings.helpImprove": "Help improve the app", - "settings.telemetryHelp": "Send anonymous feature usage statistics and crash reports.", - "settings.support": "Support / feedback", - "settings.reportIssue": "Report an issue", - "settings.homepage": "Homepage", - "settings.github": "GitHub", - "settings.followX": "Follow on X", - "language.english": "English", - "language.simplifiedChinese": "简体中文", - "model.transcriptSource": "Transcript source", - "model.language": "Language", - "model.transcriptLanguage": "Transcript language", - "model.importTranscript": "Import transcript", - "model.baseDescription": "Faster download and transcription. Good for most clips.", - "model.smallDescription": "More accurate on longer or noisier audio. Larger download.", - "model.parakeetDescription": "NVIDIA FastConformer — faster on WebGPU, strong EU-language accuracy. Auto-detects language.", - "upload.recentProjects": "Recent projects", - "upload.removeRecent": "Remove from recent", - "upload.dropOrBrowse": "Drop a video or audio file, or {browse}", - "upload.browse": "browse", - "upload.chooseTranscriptFirst": "Pick a transcript in the menu above, then drop your media", - "upload.willUseTranscript": "Will use {name} · MP4, WebM, MOV, MP3, WAV, …", - "upload.mediaFormats": "MP4, WebM, MOV, MP3, WAV, M4A, …", - "upload.gettingReady": "Getting things ready", - "upload.gettingReadyHelp": "Preparing the local media engine…", - "upload.unsupported": "This browser cannot run the local media engine", - "upload.unsupportedHelp": "Use a current Chromium-based browser or the desktop app.", - "upload.transcribeTitle": "Transcribe", - "upload.transcribeText": "Whisper locally, or import SRT / VTT.", - "upload.editTitle": "Edit", - "upload.editText": "Select words and hit delete to edit.", - "upload.exportTitle": "Export", - "upload.exportText": "Render the final cut to MP4 or M4A.", - "upload.justNow": "just now", - "editor.chooseTranscript": "Choose a transcript file from the source menu first.", - "editor.chooseMedia": "Please choose a video or audio file.", - "editor.undo": "Undo (⌘Z)", - "editor.redo": "Redo (⇧⌘Z)", - "editor.export": "Export", - "topbar.startOver": "Start over", - "transcript.replace": "Replace transcript from SRT, VTT, or JSON", - "transcript.header": "Transcript", - "transcript.wordsDeleted": "{count} words", - "transcript.replaceConfirm": "Replace the current transcript with this file?", - "transcript.invalidFile": "Choose an SRT, VTT, or JSON transcript file.", - "transcript.noSpeech": "No speech detected. Make sure this file has audio, or import a transcript.", - "transcript.cut": "Cut", - "transcript.follow": "Follow playhead", - "transcript.hideDeleted": "Hide deleted words", - "transcript.showDeleted": "Show deleted words", - "transcript.correct": "Correct", - "transcript.scrollWithPlayhead": "Scroll with the playhead", - "transcript.joinSplit": "Clip split — click to join these clips", - "transcript.joinClips": "Join clips", - "transcript.hesitation": "Detected hesitation (not transcribed) — cut with Remove filler words", - "tools.bulk": "Bulk transcript cleanups", - "tools.removeFillers": "Remove filler words", - "tools.removeFillersTitle": "Cut filler words (\"um\", \"uh\", \"...\", …) from the media", - "tools.restoreFillers": "Restore filler words", - "tools.restoreFillersTitle": "Bring every cut filler word back", - "tools.removeSilences": "Remove silences", - "tools.removeSilencesTitle": "Cut pauses and silences (≥{seconds}s) from the media", - "tools.restoreSilences": "Restore silences", - "tools.restoreSilencesTitle": "Bring every cut pause back", - "timeline.removed": "{trimmed} removed — original length {duration}", - "timeline.back5": "Back 5 s", - "timeline.playPause": "Play / pause (space)", - "timeline.forward5": "Forward 5 s", - "timeline.split": "Split", - "timeline.splitTitle": "Split clip at playhead (S)", - "timeline.splitDisabled": "Move the playhead onto a kept region to split", - "timeline.delete": "Delete", - "timeline.deleteTitle": "Delete selected clip (Delete)", - "timeline.deleteDisabled": "Select a clip on the timeline to delete", - "timeline.restore": "Restore", - "timeline.restoreTitle": "Restore selected cut (Delete)", - "timeline.restoreDisabled": "Select a cut or silence section on the timeline to restore", - "timeline.zoomOut": "Zoom out", - "timeline.fit": "Fit to window", - "timeline.zoomIn": "Zoom in — drag word edges to refine timing", - "timeline.joinClips": "Join these clips (remove split)", - "timeline.trimStart": "Trim clip start", - "timeline.trimEnd": "Trim clip end", - "timeline.hesitationAdjust": "… detected hesitation — drag edges to adjust, or Remove filler words to cut", - "timeline.hesitationCut": "… detected hesitation — cut with Remove filler words", - "timeline.dragTiming": "{word} — drag edges to adjust timing", - "timeline.scrollZoom": "Scroll to zoom in/out", - "speaker.moveStart": "Drag to move where this speaker starts", - "speaker.moveLabel": "Move speaker label", - "speaker.options": "Speaker options", - "speaker.change": "Change speaker", - "speaker.rename": "Rename speaker", - "speaker.renameAction": "Rename", - "speaker.replace": "Replace in project with…", - "speaker.removeProject": "Remove from project", - "speaker.newName": "New name", - "speaker.find": "Find a speaker…", - "speaker.noMatches": "No matching speakers", - "speaker.renameTo": "Rename {current} to {next}", - "speaker.button": "Speaker", - "speaker.search": "Search or create…", - "speaker.defaultName": "Speaker {number}", - "speaker.create": "Create \"{name}\"", - "export.title": "Export", - "export.type": "Export type", - "export.video": "Video", - "export.audio": "Audio", - "export.transcript": "Transcript", - "export.subtitles": "Subtitles", - "export.videoUnavailable": "Video export isn’t available for audio-only projects", - "export.noAudio": "This file has no audio track", - "export.noWordsFirst": "Transcribe or import a transcript first", - "export.format": "Format", - "export.resolution": "Resolution", - "export.original": "Original", - "export.plainText": "Plain text", - "export.statOriginal": "Original", - "export.statCuts": "Cuts", - "export.statEdited": "Edited", - "export.transcriptHelp": "Speaker-labeled text with cuts removed. No timestamps.", - "export.subtitlesHelp": "SRT and VTT use the edited timeline with cuts applied. JSON keeps the full word list for re-import.", - "export.encodingHelp": "Re-encoding on your device — longer files take a while.", - "export.reexport": "Re-export with latest edits", - "export.rendering": "Rendering on your device…", - "export.downloadFile": "Download {name}", - "export.downloadFormat": "Download .{format}", - "export.exportFormat": "Export {format}", - "export.tryAgain": "Try again", - "import.reading": "Reading transcript…", - "import.importing": "Import…", - "import.failed": "Import failed", - "import.readingFile": "Reading file…", - "import.chooseFileShort": "Choose a file…", - "import.chooseFile": "Choose an SRT, VTT, or JSON transcript", - "import.selected": "{name} selected", - "banner.faster": "Want faster transcription and exports?", - "banner.getDesktop": "Get the Rescript desktop app", - "banner.downloadFor": "Download for {platform}", - "banner.dismiss": "Dismiss", - "social.githubRepo": "GitHub repository", - "social.discordServer": "Discord server", - "social.xProfile": "X profile", - "globalError.title": "Rescript — something went wrong", - "globalError.heading": "Something went wrong", - "globalError.body": "The editor hit an unexpected error. Your saved projects are still on this device — reloading should bring them back.", - "progress.loadingMedia": "Loading media…", - "progress.loadingMediaEngine": "Loading media engine…", - "progress.extractingAudio": "Extracting audio…", - "progress.loadingSpeechModel": "Loading speech model…", - "progress.loadingSpeechCache": "Loading speech model from cache…", - "progress.downloadingSpeech": "Downloading speech model…", - "progress.gpuFallback": "GPU interrupted — continuing on CPU…", - "progress.detectingSpeech": "Detecting speech…", - "progress.detectingLanguage": "Detecting source language…", - "progress.transcribing": "Transcribing…", - "progress.loadingAlignCache": "Loading alignment model from cache…", - "progress.downloadingAlign": "Downloading alignment model…", - "progress.aligning": "Aligning words…", - "progress.speakers": "Identifying speakers…", - "error.selectModel": "Select a speech model to transcribe.", - "error.workerCrashed": "Transcription worker crashed.", - "error.mediaEngineNetwork": "Couldn't load the media engine — the connection dropped. Check your internet and try again.", - "error.processFile": "Failed to process this file.", - "error.extractAudio": "Could not extract audio from this file.", - "error.nothingToExport": "Everything has been deleted — nothing to export.", - "error.videoExport": "Export failed while rendering the video.", - "error.audioExport": "Export failed while rendering the audio.", - "error.export": "Export failed.", - "error.emptyTranscript": "That transcript file is empty.", - "error.noTimedWords": "No timed words found in that transcript.", - "error.parseJson": "Could not parse that JSON transcript.", - "error.jsonShape": "JSON must be a word array or an object containing words.", - "error.noWords": "No words to export.", - "error.projectMissing": "That project is no longer saved.", - "error.openProject": "Could not open that project.", - "error.removeProject": "Could not remove that project.", - "error.readTranscript": "Could not read that transcript.", - "error.clearRecent": "Could not clear recent projects.", - "error.modelDownload": "Couldn't finish downloading the speech model — the connection dropped. Check your internet and try again; completed parts are kept.", - "error.gpuReset": "Transcription was interrupted when the GPU reset, often after locking the screen. Please try again.", - "confirm.clearRecent": "Remove all recent projects? Their saved edits are deleted.", -} as const; - -export type MessageKey = keyof typeof en; - -const zhCN: Record = { - "app.title": "Rescript — 像编辑文字一样编辑视频", - "common.cancel": "取消", - "common.close": "关闭", - "common.delete": "删除", - "common.download": "下载", - "common.import": "导入", - "common.loading": "正在加载", - "common.remove": "移除", - "common.restore": "恢复", - "common.retry": "重试", - "common.searchOrCreate": "搜索或新建…", - "common.settings": "设置", - "common.system": "跟随系统", - "common.tools": "工具", - "settings.appearance": "外观", - "settings.light": "浅色", - "settings.dark": "深色", - "settings.interfaceLanguage": "界面语言", - "settings.privacy": "隐私", - "settings.helpImprove": "帮助改进应用", - "settings.telemetryHelp": "发送匿名功能使用统计和崩溃报告。", - "settings.support": "支持与反馈", - "settings.reportIssue": "报告问题", - "settings.homepage": "主页", - "settings.github": "GitHub", - "settings.followX": "在 X 上关注", - "language.english": "English", - "language.simplifiedChinese": "简体中文", - "model.transcriptSource": "转录来源", - "model.language": "语言", - "model.transcriptLanguage": "转录语言", - "model.importTranscript": "导入转录文本", - "model.baseDescription": "下载和转录更快,适合大多数片段。", - "model.smallDescription": "较长或噪声较多的音频更准确,但下载体积更大。", - "model.parakeetDescription": "NVIDIA FastConformer;WebGPU 下更快,擅长欧洲语言并自动识别语种。", - "upload.recentProjects": "最近项目", - "upload.removeRecent": "从最近项目中移除", - "upload.dropOrBrowse": "拖入视频或音频文件,或者{browse}", - "upload.browse": "浏览文件", - "upload.chooseTranscriptFirst": "请先在上方菜单选择转录文本,再拖入媒体文件", - "upload.willUseTranscript": "将使用 {name} · MP4、WebM、MOV、MP3、WAV 等", - "upload.mediaFormats": "MP4、WebM、MOV、MP3、WAV、M4A 等", - "upload.gettingReady": "正在准备", - "upload.gettingReadyHelp": "正在准备本地媒体引擎…", - "upload.unsupported": "此浏览器无法运行本地媒体引擎", - "upload.unsupportedHelp": "请使用新版 Chromium 浏览器或桌面客户端。", - "upload.transcribeTitle": "转录", - "upload.transcribeText": "在本机运行 Whisper,或导入 SRT / VTT。", - "upload.editTitle": "编辑", - "upload.editText": "选择文字并按删除键即可剪除内容。", - "upload.exportTitle": "导出", - "upload.exportText": "将最终成片导出为 MP4 或 M4A。", - "upload.justNow": "刚刚", - "editor.chooseTranscript": "请先从转录来源菜单选择一个转录文本文件。", - "editor.chooseMedia": "请选择视频或音频文件。", - "editor.undo": "撤销 (⌘Z)", - "editor.redo": "重做 (⇧⌘Z)", - "editor.export": "导出", - "topbar.startOver": "重新开始", - "transcript.replace": "用 SRT、VTT 或 JSON 替换转录文本", - "transcript.header": "转录文本", - "transcript.wordsDeleted": "已删除 {count} 个词", - "transcript.replaceConfirm": "要用此文件替换当前转录文本吗?", - "transcript.invalidFile": "请选择 SRT、VTT 或 JSON 转录文本文件。", - "transcript.noSpeech": "未检测到语音。请确认文件包含音轨,或导入转录文本。", - "transcript.cut": "剪除", - "transcript.follow": "跟随播放位置", - "transcript.hideDeleted": "隐藏已删除文字", - "transcript.showDeleted": "显示已删除文字", - "transcript.correct": "纠正", - "transcript.scrollWithPlayhead": "跟随播放位置滚动", - "transcript.joinSplit": "片段分割点 — 点击以合并片段", - "transcript.joinClips": "合并片段", - "transcript.hesitation": "检测到未转录的停顿语气,可用“移除语气词”剪除", - "tools.bulk": "批量清理转录文本", - "tools.removeFillers": "移除语气词", - "tools.removeFillersTitle": "从媒体中剪除“嗯”“呃”“…”等语气词", - "tools.restoreFillers": "恢复语气词", - "tools.restoreFillersTitle": "恢复所有已剪除的语气词", - "tools.removeSilences": "移除静音", - "tools.removeSilencesTitle": "剪除不少于 {seconds} 秒的停顿和静音", - "tools.restoreSilences": "恢复静音", - "tools.restoreSilencesTitle": "恢复所有已剪除的停顿", - "timeline.removed": "已剪除 {trimmed} — 原始长度 {duration}", - "timeline.back5": "后退 5 秒", - "timeline.playPause": "播放 / 暂停(空格)", - "timeline.forward5": "前进 5 秒", - "timeline.split": "分割", - "timeline.splitTitle": "在播放位置分割片段(S)", - "timeline.splitDisabled": "请把播放位置移到保留区域后再分割", - "timeline.delete": "删除", - "timeline.deleteTitle": "删除所选片段(Delete)", - "timeline.deleteDisabled": "请先在时间轴上选择要删除的片段", - "timeline.restore": "恢复", - "timeline.restoreTitle": "恢复所选剪除区域(Delete)", - "timeline.restoreDisabled": "请先选择要恢复的剪除或静音区域", - "timeline.zoomOut": "缩小", - "timeline.fit": "适合窗口", - "timeline.zoomIn": "放大 — 拖动文字边缘可微调时间", - "timeline.joinClips": "合并片段(移除分割点)", - "timeline.trimStart": "修剪片段开头", - "timeline.trimEnd": "修剪片段结尾", - "timeline.hesitationAdjust": "检测到停顿语气 — 可拖动边缘调整,或用“移除语气词”剪除", - "timeline.hesitationCut": "检测到停顿语气 — 可用“移除语气词”剪除", - "timeline.dragTiming": "{word} — 拖动边缘可调整时间", - "timeline.scrollZoom": "滚动以缩放时间轴", - "speaker.moveStart": "拖动以调整此说话人的起始位置", - "speaker.moveLabel": "移动说话人标签", - "speaker.options": "说话人选项", - "speaker.change": "更改说话人", - "speaker.rename": "重命名说话人", - "speaker.renameAction": "重命名", - "speaker.replace": "在整个项目中替换为…", - "speaker.removeProject": "从项目中移除", - "speaker.newName": "新名称", - "speaker.find": "查找说话人…", - "speaker.noMatches": "没有匹配的说话人", - "speaker.renameTo": "将 {current} 重命名为 {next}", - "speaker.button": "说话人", - "speaker.search": "搜索或新建…", - "speaker.defaultName": "说话人 {number}", - "speaker.create": "新建“{name}”", - "export.title": "导出", - "export.type": "导出类型", - "export.video": "视频", - "export.audio": "音频", - "export.transcript": "转录文本", - "export.subtitles": "字幕", - "export.videoUnavailable": "纯音频项目不能导出视频", - "export.noAudio": "此文件没有音轨", - "export.noWordsFirst": "请先转录或导入转录文本", - "export.format": "格式", - "export.resolution": "分辨率", - "export.original": "原始", - "export.plainText": "纯文本", - "export.statOriginal": "原始", - "export.statCuts": "剪除", - "export.statEdited": "成片", - "export.transcriptHelp": "带说话人标签的文本,已移除剪除内容,不含时间戳。", - "export.subtitlesHelp": "SRT 和 VTT 使用已应用剪除的成片时间轴;JSON 保留完整词表以便重新导入。", - "export.encodingHelp": "正在本机重新编码,较长文件需要更多时间。", - "export.reexport": "按最新编辑重新导出", - "export.rendering": "正在本机渲染…", - "export.downloadFile": "下载 {name}", - "export.downloadFormat": "下载 .{format}", - "export.exportFormat": "导出 {format}", - "export.tryAgain": "重新导出", - "import.reading": "正在读取转录文本…", - "import.importing": "正在导入…", - "import.failed": "导入失败", - "import.readingFile": "正在读取文件…", - "import.chooseFileShort": "请选择文件…", - "import.chooseFile": "选择 SRT、VTT 或 JSON 转录文本", - "import.selected": "已选择 {name}", - "banner.faster": "想要更快地转录和导出?", - "banner.getDesktop": "获取 Rescript 桌面客户端", - "banner.downloadFor": "下载 {platform} 版", - "banner.dismiss": "关闭", - "social.githubRepo": "GitHub 仓库", - "social.discordServer": "Discord 服务器", - "social.xProfile": "X 个人主页", - "globalError.title": "Rescript — 出现错误", - "globalError.heading": "出现错误", - "globalError.body": "编辑器遇到了意外错误。已保存的项目仍在此设备上,重新加载后即可恢复。", - "progress.loadingMedia": "正在加载媒体…", - "progress.loadingMediaEngine": "正在加载媒体引擎…", - "progress.extractingAudio": "正在提取音频…", - "progress.loadingSpeechModel": "正在加载语音模型…", - "progress.loadingSpeechCache": "正在从缓存加载语音模型…", - "progress.downloadingSpeech": "正在下载语音模型…", - "progress.gpuFallback": "GPU 已中断,正在改用 CPU…", - "progress.detectingSpeech": "正在检测语音…", - "progress.detectingLanguage": "正在识别源语言…", - "progress.transcribing": "正在转录…", - "progress.loadingAlignCache": "正在从缓存加载对齐模型…", - "progress.downloadingAlign": "正在下载对齐模型…", - "progress.aligning": "正在对齐文字…", - "progress.speakers": "正在识别说话人…", - "error.selectModel": "请选择用于转录的语音模型。", - "error.workerCrashed": "转录进程发生崩溃。", - "error.mediaEngineNetwork": "媒体引擎加载失败,网络连接已中断。请检查网络后重试。", - "error.processFile": "无法处理此文件。", - "error.extractAudio": "无法从此文件提取音频。", - "error.nothingToExport": "所有内容都已删除,没有可导出的内容。", - "error.videoExport": "渲染视频时导出失败。", - "error.audioExport": "渲染音频时导出失败。", - "error.export": "导出失败。", - "error.emptyTranscript": "转录文本文件为空。", - "error.noTimedWords": "转录文本中没有带时间信息的文字。", - "error.parseJson": "无法解析 JSON 转录文本。", - "error.jsonShape": "JSON 必须是文字数组,或包含 words 字段的对象。", - "error.noWords": "没有可导出的文字。", - "error.projectMissing": "此项目已不在已保存项目中。", - "error.openProject": "无法打开此项目。", - "error.removeProject": "无法移除此项目。", - "error.readTranscript": "无法读取此转录文本。", - "error.clearRecent": "无法清除最近项目。", - "error.modelDownload": "语音模型下载未完成,网络连接已中断。请检查网络后重试;已下载完成的部分会被保留。", - "error.gpuReset": "GPU 重置导致转录中断(常见于锁屏后),请重试。", - "confirm.clearRecent": "要移除所有最近项目吗?已保存的编辑也会被删除。", -}; - -export type Translate = ( - key: MessageKey, - params?: Record -) => string; - -export function isUiLocalePreference(value: unknown): value is UiLocalePreference { - return value === "system" || value === "en" || value === "zh-CN"; -} - -export function resolveUiLocale( - preference: UiLocalePreference, - systemLanguages: readonly string[] -): UiLocale { - if (preference !== "system") return preference; - for (const raw of systemLanguages) { - const language = raw.toLowerCase(); - if (language === "zh" || language.startsWith("zh-")) return "zh-CN"; - if (language === "en" || language.startsWith("en-")) return "en"; - } - return "en"; -} - -export function systemLanguages(): string[] { - if (typeof navigator === "undefined") return []; - return navigator.languages?.length - ? Array.from(navigator.languages) - : navigator.language - ? [navigator.language] - : []; -} - -export function loadUiLocalePreference(): UiLocalePreference { - if (typeof window === "undefined") return DEFAULT_UI_LOCALE_PREFERENCE; - try { - const value = window.localStorage.getItem(UI_LOCALE_STORAGE_KEY); - if (isUiLocalePreference(value)) return value; - } catch { - // Private mode / disabled storage. - } - return DEFAULT_UI_LOCALE_PREFERENCE; -} - -export function saveUiLocalePreference(preference: UiLocalePreference): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(UI_LOCALE_STORAGE_KEY, preference); - } catch { - // Private mode / disabled storage. - } -} - -export function translate( - locale: UiLocale, - key: MessageKey, - params: Record = {} -): string { - const template = (locale === "zh-CN" ? zhCN : en)[key] ?? en[key]; - return template.replace(/\{(\w+)\}/g, (token, name: string) => - Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token - ); -} - -const runtimeMessageKeys: Record = { - "Loading media…": "progress.loadingMedia", - "Loading media engine…": "progress.loadingMediaEngine", - "Extracting audio…": "progress.extractingAudio", - "Loading speech model…": "progress.loadingSpeechModel", - "Loading speech model from cache…": "progress.loadingSpeechCache", - "Downloading speech model…": "progress.downloadingSpeech", - "GPU interrupted — continuing on CPU…": "progress.gpuFallback", - "Detecting speech…": "progress.detectingSpeech", - "Detecting language…": "progress.detectingLanguage", - "Transcribing…": "progress.transcribing", - "Loading alignment model from cache…": "progress.loadingAlignCache", - "Downloading alignment model…": "progress.downloadingAlign", - "Aligning words…": "progress.aligning", - "Identifying speakers…": "progress.speakers", - "Select a speech model to transcribe.": "error.selectModel", - "Transcription worker crashed.": "error.workerCrashed", - "Couldn't load the media engine — the connection dropped. Check your internet and try again.": "error.mediaEngineNetwork", - "Failed to process this file.": "error.processFile", - "Could not extract audio from this file.": "error.extractAudio", - "Everything has been deleted — nothing to export.": "error.nothingToExport", - "Export failed while rendering the video.": "error.videoExport", - "Export failed while rendering the audio.": "error.audioExport", - "Export failed.": "error.export", - "That transcript file is empty.": "error.emptyTranscript", - "No timed words found in that transcript.": "error.noTimedWords", - "Could not parse that JSON transcript.": "error.parseJson", - 'JSON must be a word array or { "words": [...] }.': "error.jsonShape", - "No words to export.": "error.noWords", - "That project is no longer saved.": "error.projectMissing", - "Could not open that project.": "error.openProject", - "Could not remove that project.": "error.removeProject", - "Could not read that transcript.": "error.readTranscript", - "Could not clear recent projects.": "error.clearRecent", - "Couldn't finish downloading the speech model — the connection dropped. Check your internet and try again; the parts that finished downloading are kept.": "error.modelDownload", - "Transcription was interrupted when the GPU reset (often after locking the screen). Please try again.": "error.gpuReset", -}; - -export function localizeRuntimeMessage( - text: string | null | undefined, - t: Translate -): string { - if (!text) return ""; - const key = runtimeMessageKeys[text]; - return key ? t(key) : text; -} - -export function formatRelativeTime( - locale: UiLocale, - timestamp: number, - now = Date.now() -): string { - const seconds = Math.max(0, Math.round((now - timestamp) / 1000)); - const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - if (seconds < 45) return formatter.format(0, "second"); - const minutes = Math.round(seconds / 60); - if (minutes < 60) return formatter.format(-minutes, "minute"); - const hours = Math.round(minutes / 60); - if (hours < 48) return formatter.format(-hours, "hour"); - const days = Math.round(hours / 24); - if (days < 14) return formatter.format(-days, "day"); - return new Intl.DateTimeFormat(locale, { - month: "short", - day: "numeric", - }).format(timestamp); -} diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts new file mode 100644 index 0000000..e44c7ba --- /dev/null +++ b/lib/i18n/index.ts @@ -0,0 +1,121 @@ +import { en, type MessageKey } from "./messages/en"; +import { zhCN } from "./messages/zh-CN"; +import { runtimeMessageKeys } from "./runtimeMessages"; + +export type { MessageKey } from "./messages/en"; +export { + runtimeEnglishMessages, + runtimeMessageKeys, + type RuntimeMessageKey, +} from "./runtimeMessages"; + +export type UiLocale = "en" | "zh-CN"; +export type UiLocalePreference = "system" | UiLocale; + +export const DEFAULT_UI_LOCALE_PREFERENCE: UiLocalePreference = "system"; +export const UI_LOCALE_STORAGE_KEY = "rescript.ui-locale"; + +export type Translate = ( + key: MessageKey, + params?: Record +) => string; + +export function isUiLocalePreference(value: unknown): value is UiLocalePreference { + return value === "system" || value === "en" || value === "zh-CN"; +} + +/** + * Resolve the effective UI locale. + * + * For `system`, the first supported language in the list wins. Any `zh*` tag + * (including zh-HK / zh-TW) currently maps to Simplified Chinese — Traditional + * Chinese is not a separate UI locale yet. + */ +export function resolveUiLocale( + preference: UiLocalePreference, + systemLanguages: readonly string[] +): UiLocale { + if (preference !== "system") return preference; + for (const raw of systemLanguages) { + const language = raw.toLowerCase(); + if (language === "zh" || language.startsWith("zh-")) return "zh-CN"; + if (language === "en" || language.startsWith("en-")) return "en"; + } + return "en"; +} + +export function systemLanguages(): string[] { + if (typeof navigator === "undefined") return []; + return navigator.languages?.length + ? Array.from(navigator.languages) + : navigator.language + ? [navigator.language] + : []; +} + +export function loadUiLocalePreference(): UiLocalePreference { + if (typeof window === "undefined") return DEFAULT_UI_LOCALE_PREFERENCE; + try { + const value = window.localStorage.getItem(UI_LOCALE_STORAGE_KEY); + if (isUiLocalePreference(value)) return value; + } catch { + // Private mode / disabled storage. + } + return DEFAULT_UI_LOCALE_PREFERENCE; +} + +export function saveUiLocalePreference(preference: UiLocalePreference): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(UI_LOCALE_STORAGE_KEY, preference); + } catch { + // Private mode / disabled storage. + } +} + +function interpolate( + template: string, + params: Record +): string { + return template.replace(/\{(\w+)\}/g, (token, name: string) => + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token + ); +} + +export function translate( + locale: UiLocale, + key: MessageKey, + params: Record = {} +): string { + const template = (locale === "zh-CN" ? zhCN : en)[key] ?? en[key]; + return interpolate(template, params); +} + +export function localizeRuntimeMessage( + text: string | null | undefined, + t: Translate +): string { + if (!text) return ""; + const key = runtimeMessageKeys[text]; + return key ? t(key) : text; +} + +export function formatRelativeTime( + locale: UiLocale, + timestamp: number, + now = Date.now() +): string { + const seconds = Math.max(0, Math.round((now - timestamp) / 1000)); + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + if (seconds < 45) return formatter.format(0, "second"); + const minutes = Math.round(seconds / 60); + if (minutes < 60) return formatter.format(-minutes, "minute"); + const hours = Math.round(minutes / 60); + if (hours < 48) return formatter.format(-hours, "hour"); + const days = Math.round(hours / 24); + if (days < 14) return formatter.format(-days, "day"); + return new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + }).format(timestamp); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts new file mode 100644 index 0000000..b6aab16 --- /dev/null +++ b/lib/i18n/messages/en.ts @@ -0,0 +1,207 @@ +/** English UI catalog. Runtime English emitters must match these values exactly + * when they flow through {@link localizeRuntimeMessage}. */ +export const en = { + "app.title": "Rescript — edit videos like you edit text", + "common.cancel": "Cancel", + "common.close": "Close", + "common.delete": "Delete", + "common.download": "Download", + "common.import": "Import", + "common.loading": "Loading", + "common.remove": "Remove", + "common.restore": "Restore", + "common.retry": "Try again", + "common.searchOrCreate": "Search or create…", + "common.settings": "Settings", + "common.system": "System", + "common.tools": "Tools", + "settings.appearance": "Appearance", + "settings.light": "Light", + "settings.dark": "Dark", + "settings.interfaceLanguage": "Interface language", + "settings.privacy": "Privacy", + "settings.helpImprove": "Help improve the app", + "settings.telemetryHelp": "Send anonymous feature usage statistics and crash reports.", + "settings.support": "Support / feedback", + "settings.reportIssue": "Report an issue", + "settings.homepage": "Homepage", + "settings.github": "GitHub", + "settings.followX": "Follow on X", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "Transcript source", + "model.language": "Language", + "model.transcriptLanguage": "Transcript language", + "model.importTranscript": "Import transcript", + "upload.recentProjects": "Recent projects", + "upload.removeRecent": "Remove from recent", + "upload.dropPrefix": "Drop a video or audio file here, or", + "upload.browse": "browse", + "upload.chooseTranscriptFirst": "Pick a transcript in the menu above, then drop your media", + "upload.willUseTranscript": "Will use {name} · MP4, WebM, MOV, MP3, WAV, …", + "upload.mediaFormats": "MP4, WebM, MOV, MP3, WAV, M4A, …", + "upload.gettingReady": "Getting things ready", + "upload.gettingReadyHelp": "Setting up the media engine, this only happens once.", + "upload.unsupported": "This browser can't run the editor", + "upload.unsupportedHelp": + "Editing needs SharedArrayBuffer, which requires a cross-origin-isolated page. Try a recent Chrome, Edge, Safari or Firefox over HTTPS.", + "upload.transcribeTitle": "Transcribe", + "upload.transcribeText": "Whisper locally, or import SRT / VTT.", + "upload.editTitle": "Edit", + "upload.editText": "Select words and hit delete to edit.", + "upload.exportTitle": "Export", + "upload.exportText": "Render the final cut to MP4 or M4A.", + "editor.chooseTranscript": "Choose a transcript file from the source menu first.", + "editor.chooseMedia": "Please choose a video or audio file.", + "editor.undo": "Undo (⌘Z)", + "editor.redo": "Redo (⇧⌘Z)", + "editor.export": "Export", + "topbar.startOver": "Start over", + "transcript.replace": "Replace transcript from SRT, VTT, or JSON", + "transcript.header": "Transcript", + "transcript.wordDeleted": "{count} word", + "transcript.wordsDeleted": "{count} words", + "transcript.replaceConfirm": "Replace the current transcript with this file?", + "transcript.invalidFile": "Choose an SRT, VTT, or JSON transcript file.", + "transcript.noSpeech": "No speech detected. Make sure this file has audio, or import a transcript.", + "transcript.cut": "Cut", + "transcript.follow": "Follow playhead", + "transcript.hideDeleted": "Hide deleted words", + "transcript.showDeleted": "Show deleted words", + "transcript.correct": "Correct", + "transcript.scrollWithPlayhead": "Scroll with the playhead", + "transcript.joinSplit": "Clip split — click to join these clips", + "transcript.joinClips": "Join clips", + "transcript.hesitation": "Detected hesitation (not transcribed) — cut with Remove filler words", + "tools.bulk": "Bulk transcript cleanups", + "tools.removeFillers": "Remove filler words", + "tools.removeFillersTitle": 'Cut filler words ("um", "uh", "...", …) from the video', + "tools.restoreFillers": "Restore filler words", + "tools.restoreFillersTitle": "Bring every cut filler word back", + "tools.removeSilences": "Remove silences", + "tools.removeSilencesTitle": "Cut pauses and silences (≥{seconds}s) from the video", + "tools.restoreSilences": "Restore silences", + "tools.restoreSilencesTitle": "Bring every cut pause back", + "timeline.removed": "{trimmed} removed — original length {duration}", + "timeline.back5": "Back 5 s", + "timeline.playPause": "Play / pause (space)", + "timeline.forward5": "Forward 5 s", + "timeline.split": "Split", + "timeline.splitTitle": "Split clip at playhead (S)", + "timeline.splitDisabled": "Move the playhead onto a kept region to split", + "timeline.delete": "Delete", + "timeline.deleteTitle": "Delete selected clip (Delete)", + "timeline.deleteDisabled": "Select a clip on the timeline to delete", + "timeline.restore": "Restore", + "timeline.restoreTitle": "Restore selected cut (Delete)", + "timeline.restoreDisabled": "Select a cut or silence section on the timeline to restore", + "timeline.zoomOut": "Zoom out", + "timeline.fit": "Fit to window", + "timeline.zoomIn": "Zoom in — drag word edges to refine timing", + "timeline.joinClips": "Join these clips (remove split)", + "timeline.trimStart": "Trim clip start", + "timeline.trimEnd": "Trim clip end", + "timeline.hesitationAdjust": "… detected hesitation — drag edges to adjust, or Remove filler words to cut", + "timeline.hesitationCut": "… detected hesitation — cut with Remove filler words", + "timeline.dragTiming": "{word} — drag edges to adjust timing", + "timeline.scrollZoom": "Scroll to zoom in/out", + "speaker.moveStart": "Drag to move where this speaker starts", + "speaker.moveLabel": "Move speaker label", + "speaker.options": "Speaker options", + "speaker.change": "Change speaker", + "speaker.rename": "Rename speaker", + "speaker.renameAction": "Rename", + "speaker.replace": "Replace in project with…", + "speaker.removeProject": "Remove from project", + "speaker.newName": "New name", + "speaker.find": "Find a speaker…", + "speaker.noMatches": "No matching speakers", + "speaker.renameTo": "Rename {current} to {next}", + "speaker.button": "Speaker", + "speaker.search": "Search or create…", + "speaker.defaultName": "Speaker {number}", + "speaker.create": 'Create "{name}"', + "export.title": "Export", + "export.type": "Export type", + "export.video": "Video", + "export.audio": "Audio", + "export.transcript": "Transcript", + "export.subtitles": "Subtitles", + "export.videoUnavailable": "Video export isn’t available for audio-only projects", + "export.noAudio": "This file has no audio track", + "export.noWordsFirst": "Transcribe or import a transcript first", + "export.format": "Format", + "export.resolution": "Resolution", + "export.original": "Original", + "export.plainText": "Plain text", + "export.statOriginal": "Original", + "export.statCuts": "Cuts", + "export.statEdited": "Edited", + "export.transcriptHelp": "Speaker-labeled text with cuts removed. No timestamps.", + "export.subtitlesHelp": + "SRT and VTT use the edited timeline (cuts applied). JSON keeps the full word list for re-import.", + "export.encodingHelp": "Re-encoding with ffmpeg.wasm — longer files take a while.", + "export.reexport": "Re-export with latest edits", + "export.rendering": "Rendering in your browser…", + "export.downloadFile": "Download {name}", + "export.downloadFormat": "Download .{format}", + "export.exportFormat": "Export {format}", + "import.reading": "Reading transcript…", + "import.importing": "Import…", + "import.failed": "Import failed", + "import.readingFile": "Reading file…", + "import.chooseFileShort": "Choose a file…", + "import.chooseFile": "Choose an SRT, VTT, or JSON transcript", + "import.selected": "{name} selected", + "banner.faster": "Want faster transcription and exports?", + "banner.getDesktop": "Get the Rescript desktop app", + "banner.downloadFor": "Download for {platform}", + "banner.dismiss": "Dismiss", + "social.githubRepo": "GitHub repository", + "social.discordServer": "Discord server", + "social.xProfile": "X profile", + "globalError.title": "Rescript — something went wrong", + "globalError.heading": "Something went wrong", + "globalError.body": + "The editor hit an unexpected error. Your saved projects are still on this device — reloading should bring them back.", + "progress.loadingMedia": "Loading media…", + "progress.loadingMediaEngine": "Loading media engine…", + "progress.extractingAudio": "Extracting audio…", + "progress.loadingSpeechModel": "Loading speech model…", + "progress.loadingSpeechCache": "Loading speech model from cache…", + "progress.downloadingSpeech": "Downloading speech model…", + "progress.gpuFallback": "GPU interrupted — continuing on CPU…", + "progress.detectingSpeech": "Detecting speech…", + "progress.transcribing": "Transcribing…", + "progress.loadingAlignCache": "Loading alignment model from cache…", + "progress.downloadingAlign": "Downloading alignment model…", + "progress.aligning": "Aligning words…", + "progress.speakers": "Identifying speakers…", + "error.selectModel": "Select a speech model to transcribe.", + "error.workerCrashed": "Transcription worker crashed.", + "error.mediaEngineNetwork": + "Couldn't load the media engine — the connection dropped. Check your internet and try again.", + "error.processFile": "Failed to process this file.", + "error.extractAudio": "Could not extract audio from this file.", + "error.nothingToExport": "Everything has been deleted — nothing to export.", + "error.videoExport": "Export failed while rendering the video.", + "error.audioExport": "Export failed while rendering the audio.", + "error.export": "Export failed.", + "error.emptyTranscript": "That transcript file is empty.", + "error.noTimedWords": "No timed words found in that transcript.", + "error.parseJson": "Could not parse that JSON transcript.", + "error.jsonShape": 'JSON must be a word array or { "words": [...] }.', + "error.noWords": "No words to export.", + "error.projectMissing": "That project is no longer saved.", + "error.openProject": "Could not open that project.", + "error.removeProject": "Could not remove that project.", + "error.readTranscript": "Could not read that transcript.", + "error.clearRecent": "Could not clear recent projects.", + "error.modelDownload": + "Couldn't finish downloading the speech model — the connection dropped. Check your internet and try again; the parts that finished downloading are kept.", + "error.gpuReset": + "Transcription was interrupted when the GPU reset (often after locking the screen). Please try again.", + "confirm.clearRecent": "Remove all recent projects? Their saved edits are deleted.", +} as const; + +export type MessageKey = keyof typeof en; diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts new file mode 100644 index 0000000..78fb160 --- /dev/null +++ b/lib/i18n/messages/zh-CN.ts @@ -0,0 +1,206 @@ +import type { MessageKey } from "./en"; + +/** Simplified Chinese UI catalog. Every key in {@link en} must be present. */ +export const zhCN: Record = { + "app.title": "Rescript — 像编辑文字一样编辑视频", + "common.cancel": "取消", + "common.close": "关闭", + "common.delete": "删除", + "common.download": "下载", + "common.import": "导入", + "common.loading": "正在加载", + "common.remove": "移除", + "common.restore": "恢复", + "common.retry": "重试", + "common.searchOrCreate": "搜索或新建…", + "common.settings": "设置", + "common.system": "跟随系统", + "common.tools": "工具", + "settings.appearance": "外观", + "settings.light": "浅色", + "settings.dark": "深色", + "settings.interfaceLanguage": "界面语言", + "settings.privacy": "隐私", + "settings.helpImprove": "帮助改进应用", + "settings.telemetryHelp": "发送匿名功能使用统计和崩溃报告。", + "settings.support": "支持与反馈", + "settings.reportIssue": "报告问题", + "settings.homepage": "主页", + "settings.github": "GitHub", + "settings.followX": "在 X 上关注", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "转录来源", + "model.language": "语言", + "model.transcriptLanguage": "转录语言", + "model.importTranscript": "导入转录文本", + "upload.recentProjects": "最近项目", + "upload.removeRecent": "从最近项目中移除", + "upload.dropPrefix": "将视频或音频文件拖到此处,或者", + "upload.browse": "浏览文件", + "upload.chooseTranscriptFirst": "请先在上方菜单选择转录文本,再拖入媒体文件", + "upload.willUseTranscript": "将使用 {name} · MP4、WebM、MOV、MP3、WAV 等", + "upload.mediaFormats": "MP4、WebM、MOV、MP3、WAV、M4A 等", + "upload.gettingReady": "正在准备", + "upload.gettingReadyHelp": "正在设置媒体引擎,仅需一次。", + "upload.unsupported": "此浏览器无法运行编辑器", + "upload.unsupportedHelp": + "编辑功能需要 SharedArrayBuffer,因此页面必须处于跨源隔离状态。请使用较新的 Chrome、Edge、Safari 或 Firefox,并通过 HTTPS 访问。", + "upload.transcribeTitle": "转录", + "upload.transcribeText": "在本机运行 Whisper,或导入 SRT / VTT。", + "upload.editTitle": "编辑", + "upload.editText": "选择文字并按删除键即可剪除内容。", + "upload.exportTitle": "导出", + "upload.exportText": "将最终成片导出为 MP4 或 M4A。", + "editor.chooseTranscript": "请先从转录来源菜单选择一个转录文本文件。", + "editor.chooseMedia": "请选择视频或音频文件。", + "editor.undo": "撤销 (⌘Z)", + "editor.redo": "重做 (⇧⌘Z)", + "editor.export": "导出", + "topbar.startOver": "重新开始", + "transcript.replace": "用 SRT、VTT 或 JSON 替换转录文本", + "transcript.header": "转录文本", + "transcript.wordDeleted": "已删除 {count} 个词", + "transcript.wordsDeleted": "已删除 {count} 个词", + "transcript.replaceConfirm": "要用此文件替换当前转录文本吗?", + "transcript.invalidFile": "请选择 SRT、VTT 或 JSON 转录文本文件。", + "transcript.noSpeech": "未检测到语音。请确认文件包含音轨,或导入转录文本。", + "transcript.cut": "剪除", + "transcript.follow": "跟随播放位置", + "transcript.hideDeleted": "隐藏已删除文字", + "transcript.showDeleted": "显示已删除文字", + "transcript.correct": "纠正", + "transcript.scrollWithPlayhead": "跟随播放位置滚动", + "transcript.joinSplit": "片段分割点 — 点击以合并片段", + "transcript.joinClips": "合并片段", + "transcript.hesitation": "检测到未转录的停顿语气,可用“移除语气词”剪除", + "tools.bulk": "批量清理转录文本", + "tools.removeFillers": "移除语气词", + "tools.removeFillersTitle": "从视频中剪除“嗯”“呃”“…”等语气词", + "tools.restoreFillers": "恢复语气词", + "tools.restoreFillersTitle": "恢复所有已剪除的语气词", + "tools.removeSilences": "移除静音", + "tools.removeSilencesTitle": "剪除不少于 {seconds} 秒的停顿和静音", + "tools.restoreSilences": "恢复静音", + "tools.restoreSilencesTitle": "恢复所有已剪除的停顿", + "timeline.removed": "已剪除 {trimmed} — 原始长度 {duration}", + "timeline.back5": "后退 5 秒", + "timeline.playPause": "播放 / 暂停(空格)", + "timeline.forward5": "前进 5 秒", + "timeline.split": "分割", + "timeline.splitTitle": "在播放位置分割片段(S)", + "timeline.splitDisabled": "请把播放位置移到保留区域后再分割", + "timeline.delete": "删除", + "timeline.deleteTitle": "删除所选片段(Delete)", + "timeline.deleteDisabled": "请先在时间轴上选择要删除的片段", + "timeline.restore": "恢复", + "timeline.restoreTitle": "恢复所选剪除区域(Delete)", + "timeline.restoreDisabled": "请先选择要恢复的剪除或静音区域", + "timeline.zoomOut": "缩小", + "timeline.fit": "适合窗口", + "timeline.zoomIn": "放大 — 拖动文字边缘可微调时间", + "timeline.joinClips": "合并片段(移除分割点)", + "timeline.trimStart": "修剪片段开头", + "timeline.trimEnd": "修剪片段结尾", + "timeline.hesitationAdjust": "检测到停顿语气 — 可拖动边缘调整,或用“移除语气词”剪除", + "timeline.hesitationCut": "检测到停顿语气 — 可用“移除语气词”剪除", + "timeline.dragTiming": "{word} — 拖动边缘可调整时间", + "timeline.scrollZoom": "滚动以缩放时间轴", + "speaker.moveStart": "拖动以调整此说话人的起始位置", + "speaker.moveLabel": "移动说话人标签", + "speaker.options": "说话人选项", + "speaker.change": "更改说话人", + "speaker.rename": "重命名说话人", + "speaker.renameAction": "重命名", + "speaker.replace": "在整个项目中替换为…", + "speaker.removeProject": "从项目中移除", + "speaker.newName": "新名称", + "speaker.find": "查找说话人…", + "speaker.noMatches": "没有匹配的说话人", + "speaker.renameTo": "将 {current} 重命名为 {next}", + "speaker.button": "说话人", + "speaker.search": "搜索或新建…", + "speaker.defaultName": "说话人 {number}", + "speaker.create": "新建“{name}”", + "export.title": "导出", + "export.type": "导出类型", + "export.video": "视频", + "export.audio": "音频", + "export.transcript": "转录文本", + "export.subtitles": "字幕", + "export.videoUnavailable": "纯音频项目不能导出视频", + "export.noAudio": "此文件没有音轨", + "export.noWordsFirst": "请先转录或导入转录文本", + "export.format": "格式", + "export.resolution": "分辨率", + "export.original": "原始", + "export.plainText": "纯文本", + "export.statOriginal": "原始", + "export.statCuts": "剪除", + "export.statEdited": "成片", + "export.transcriptHelp": "带说话人标签的文本,已移除剪除内容,不含时间戳。", + "export.subtitlesHelp": + "SRT 和 VTT 使用已应用剪除的成片时间轴;JSON 保留完整词表以便重新导入。", + "export.encodingHelp": "使用 ffmpeg.wasm 重新编码,较长文件需要更多时间。", + "export.reexport": "按最新编辑重新导出", + "export.rendering": "正在浏览器中渲染…", + "export.downloadFile": "下载 {name}", + "export.downloadFormat": "下载 .{format}", + "export.exportFormat": "导出 {format}", + "import.reading": "正在读取转录文本…", + "import.importing": "正在导入…", + "import.failed": "导入失败", + "import.readingFile": "正在读取文件…", + "import.chooseFileShort": "请选择文件…", + "import.chooseFile": "选择 SRT、VTT 或 JSON 转录文本", + "import.selected": "已选择 {name}", + "banner.faster": "想要更快地转录和导出?", + "banner.getDesktop": "获取 Rescript 桌面客户端", + "banner.downloadFor": "下载 {platform} 版", + "banner.dismiss": "关闭", + "social.githubRepo": "GitHub 仓库", + "social.discordServer": "Discord 服务器", + "social.xProfile": "X 个人主页", + "globalError.title": "Rescript — 出现错误", + "globalError.heading": "出现错误", + "globalError.body": + "编辑器遇到了意外错误。已保存的项目仍在此设备上,重新加载后即可恢复。", + "progress.loadingMedia": "正在加载媒体…", + "progress.loadingMediaEngine": "正在加载媒体引擎…", + "progress.extractingAudio": "正在提取音频…", + "progress.loadingSpeechModel": "正在加载语音模型…", + "progress.loadingSpeechCache": "正在从缓存加载语音模型…", + "progress.downloadingSpeech": "正在下载语音模型…", + "progress.gpuFallback": "GPU 已中断,正在改用 CPU…", + "progress.detectingSpeech": "正在检测语音…", + "progress.transcribing": "正在转录…", + "progress.loadingAlignCache": "正在从缓存加载对齐模型…", + "progress.downloadingAlign": "正在下载对齐模型…", + "progress.aligning": "正在对齐文字…", + "progress.speakers": "正在识别说话人…", + "error.selectModel": "请选择用于转录的语音模型。", + "error.workerCrashed": "转录进程发生崩溃。", + "error.mediaEngineNetwork": + "媒体引擎加载失败,网络连接已中断。请检查网络后重试。", + "error.processFile": "无法处理此文件。", + "error.extractAudio": "无法从此文件提取音频。", + "error.nothingToExport": "所有内容都已删除,没有可导出的内容。", + "error.videoExport": "渲染视频时导出失败。", + "error.audioExport": "渲染音频时导出失败。", + "error.export": "导出失败。", + "error.emptyTranscript": "转录文本文件为空。", + "error.noTimedWords": "转录文本中没有带时间信息的文字。", + "error.parseJson": "无法解析 JSON 转录文本。", + "error.jsonShape": 'JSON 必须是文字数组,或包含 words 字段的对象。', + "error.noWords": "没有可导出的文字。", + "error.projectMissing": "此项目已不在已保存项目中。", + "error.openProject": "无法打开此项目。", + "error.removeProject": "无法移除此项目。", + "error.readTranscript": "无法读取此转录文本。", + "error.clearRecent": "无法清除最近项目。", + "error.modelDownload": + "语音模型下载未完成,网络连接已中断。请检查网络后重试;已下载完成的部分会被保留。", + "error.gpuReset": + "GPU 重置导致转录中断(常见于锁屏后),请重试。", + "confirm.clearRecent": "要移除所有最近项目吗?已保存的编辑也会被删除。", +}; diff --git a/lib/i18n/runtimeMessages.ts b/lib/i18n/runtimeMessages.ts new file mode 100644 index 0000000..ee7a020 --- /dev/null +++ b/lib/i18n/runtimeMessages.ts @@ -0,0 +1,54 @@ +import { en, type MessageKey } from "./messages/en"; + +/** + * English strings emitted by workers / store / parsers that the UI localizes + * after the fact. Keys are taken from the English catalog so the map cannot + * drift from {@link en}. + */ +const runtimeMessageKeyList = [ + "progress.loadingMedia", + "progress.loadingMediaEngine", + "progress.extractingAudio", + "progress.loadingSpeechModel", + "progress.loadingSpeechCache", + "progress.downloadingSpeech", + "progress.gpuFallback", + "progress.detectingSpeech", + "progress.transcribing", + "progress.loadingAlignCache", + "progress.downloadingAlign", + "progress.aligning", + "progress.speakers", + "error.selectModel", + "error.workerCrashed", + "error.mediaEngineNetwork", + "error.processFile", + "error.extractAudio", + "error.nothingToExport", + "error.videoExport", + "error.audioExport", + "error.export", + "error.emptyTranscript", + "error.noTimedWords", + "error.parseJson", + "error.jsonShape", + "error.noWords", + "error.projectMissing", + "error.openProject", + "error.removeProject", + "error.readTranscript", + "error.clearRecent", + "error.modelDownload", + "error.gpuReset", +] as const satisfies readonly MessageKey[]; + +export type RuntimeMessageKey = (typeof runtimeMessageKeyList)[number]; + +export const runtimeMessageKeys: Record = Object.fromEntries( + runtimeMessageKeyList.map((key) => [en[key], key]) +); + +/** English strings currently expected from runtime emitters (for tests). */ +export const runtimeEnglishMessages: readonly string[] = runtimeMessageKeyList.map( + (key) => en[key] +); diff --git a/lib/parseTranscript.ts b/lib/parseTranscript.ts index 8b19103..d59d7ca 100644 --- a/lib/parseTranscript.ts +++ b/lib/parseTranscript.ts @@ -1,10 +1,11 @@ +import { en } from "@/lib/i18n/messages/en"; import { defaultSpeakerName, speakersFromWords } from "./speakers"; import type { SpeakerInfo, Word } from "./types"; /** Caption / transcript files we can turn into timed words. */ export const TRANSCRIPT_ACCEPT = ".srt,.vtt,.json,application/json,text/vtt,text/plain"; -export const TRANSCRIPT_FILE_ERROR = "Choose an SRT, VTT, or JSON file."; +export const TRANSCRIPT_FILE_ERROR = en["transcript.invalidFile"]; const TRANSCRIPT_EXT = /\.(srt|vtt|json)$/i; @@ -38,7 +39,7 @@ export function parseTranscript( filename = "" ): ParsedTranscript { const trimmed = text.replace(/^\uFEFF/, "").trim(); - if (!trimmed) throw new Error("That transcript file is empty."); + if (!trimmed) throw new Error(en["error.emptyTranscript"]); const lower = filename.toLowerCase(); let parsed: ParsedTranscript; @@ -62,7 +63,7 @@ export function parseTranscript( } if (parsed.words.length === 0) { - throw new Error("No timed words found in that transcript."); + throw new Error(en["error.noTimedWords"]); } return { words: parsed.words, @@ -91,7 +92,7 @@ function wordsFromJson(text: string): ParsedTranscript { try { data = JSON.parse(text); } catch { - throw new Error("Could not parse that JSON transcript."); + throw new Error(en["error.parseJson"]); } const rows = Array.isArray(data) @@ -102,7 +103,7 @@ function wordsFromJson(text: string): ParsedTranscript { ? (data as { words: unknown[] }).words : null; if (!rows) { - throw new Error('JSON must be a word array or { "words": [...] }.'); + throw new Error(en["error.jsonShape"]); } const namedSpeakers = readSpeakerInfos(data); diff --git a/lib/projects.ts b/lib/projects.ts index f4e2719..fd30f5a 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -229,19 +229,3 @@ export function fileFromProject(project: ProjectRecord): File { lastModified: project.updatedAt, }); } - -/** Compact relative time for the recent list (e.g. "just now", "3h ago"). */ -export function formatRelativeTime(ts: number, now = Date.now()): string { - const sec = Math.max(0, Math.round((now - ts) / 1000)); - if (sec < 45) return "just now"; - const min = Math.round(sec / 60); - if (min < 60) return `${min}m ago`; - const hr = Math.round(min / 60); - if (hr < 48) return `${hr}h ago`; - const day = Math.round(hr / 24); - if (day < 14) return `${day}d ago`; - return new Date(ts).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); -} diff --git a/lib/serializeTranscript.ts b/lib/serializeTranscript.ts index 100fd54..51b9a08 100644 --- a/lib/serializeTranscript.ts +++ b/lib/serializeTranscript.ts @@ -1,3 +1,4 @@ +import { en } from "@/lib/i18n/messages/en"; import { getCutRanges, originalToEdited } from "./edits"; import { speakerLabel, speakersFromWords } from "./speakers"; import { groupWordsBySpeaker } from "./transcript"; @@ -75,7 +76,7 @@ export function serializeTranscript( const editedTimeline = options.editedTimeline !== false; const prepared = prepareCaptionWords(words, editedTimeline, options); if (prepared.length === 0) { - throw new Error("No words to export."); + throw new Error(en["error.noWords"]); } const cues = wordsToCues(prepared); return format === "vtt" @@ -127,7 +128,7 @@ function serializeDocument( const editedTimeline = options.editedTimeline !== false; const prepared = prepareCaptionWords(words, editedTimeline, options); if (prepared.length === 0) { - throw new Error("No words to export."); + throw new Error(en["error.noWords"]); } const speakers = options.speakers ?? speakersFromWords(prepared); diff --git a/lib/store.ts b/lib/store.ts index 4c7e867..d2f6153 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -36,6 +36,7 @@ import { saveTranscriptLanguagePreference, type TranscriptLanguage, } from "./languages"; +import { en } from "@/lib/i18n/messages/en"; import { detectMediaKind, type MediaKind } from "./media"; import { buildWaveformPeaks, type WaveformPeaks } from "./waveform"; import { @@ -382,7 +383,9 @@ export const useEditorStore = create((set, get) => ({ pendingTranscript: null, status: "preparing", progress: { - message: imported ? "Loading media…" : "Loading media engine…", + message: imported + ? en["progress.loadingMedia"] + : en["progress.loadingMediaEngine"], value: null, }, words: imported ? imported : [], @@ -415,7 +418,7 @@ export const useEditorStore = create((set, get) => ({ openProject: async (id) => { const record = await getProject(id); - if (!record) throw new Error("That project is no longer saved."); + if (!record) throw new Error(en["error.projectMissing"]); const file = fileFromProject(record); const prev = get().mediaUrl; if (prev) URL.revokeObjectURL(prev); @@ -435,7 +438,7 @@ export const useEditorStore = create((set, get) => ({ skipTranscription: true, pendingTranscript: null, status: "preparing", - progress: { message: "Loading media engine…", value: null }, + progress: { message: en["progress.loadingMediaEngine"], value: null }, words: record.words, speakers, manualCuts, diff --git a/tests/i18n-test.ts b/tests/i18n-test.ts index 2de06b8..0e38080 100644 --- a/tests/i18n-test.ts +++ b/tests/i18n-test.ts @@ -2,8 +2,12 @@ import { formatRelativeTime, localizeRuntimeMessage, resolveUiLocale, + runtimeEnglishMessages, + runtimeMessageKeys, translate, } from "../lib/i18n"; +import { en, type MessageKey } from "../lib/i18n/messages/en"; +import { zhCN } from "../lib/i18n/messages/zh-CN"; function assert(value: unknown, message: string): asserts value { if (!value) throw new Error(message); @@ -24,25 +28,61 @@ assert( "下载 demo.mp4", "named interpolation" ); +assert( + translate("en", "transcript.wordDeleted", { count: 1 }) === "1 word", + "singular words deleted" +); +assert( + translate("en", "transcript.wordsDeleted", { count: 3 }) === "3 words", + "plural words deleted" +); -const zh = (key: Parameters[1], params?: Record) => +const zh = (key: MessageKey, params?: Record) => translate("zh-CN", key, params); assert( localizeRuntimeMessage("Transcribing…", zh) === "正在转录…", "runtime progress localization" ); -assert( - localizeRuntimeMessage("Detecting language…", zh) === "正在识别源语言…", - "language detection progress localization" -); assert( localizeRuntimeMessage("No words to export.", zh) === "没有可导出的文字。", "runtime error localization" ); +assert( + localizeRuntimeMessage( + 'JSON must be a word array or { "words": [...] }.', + zh + ) === "JSON 必须是文字数组,或包含 words 字段的对象。", + "json shape localization" +); +assert( + localizeRuntimeMessage( + "Couldn't finish downloading the speech model — the connection dropped. Check your internet and try again; the parts that finished downloading are kept.", + zh + ).includes("语音模型"), + "model download localization" +); assert(localizeRuntimeMessage("Unknown diagnostic", zh) === "Unknown diagnostic", "fallback"); +// Runtime map is derived from the English catalog — every lookup key must match. +for (const english of runtimeEnglishMessages) { + assert(runtimeMessageKeys[english], `runtime map covers ${english}`); + const key = runtimeMessageKeys[english]; + assert(en[key] === english, `catalog matches runtime english for ${key}`); +} + +// Catalogs stay complete across locales. +const enKeys = Object.keys(en) as MessageKey[]; +for (const key of enKeys) { + assert(typeof zhCN[key] === "string" && zhCN[key].length > 0, `zh-CN has ${key}`); +} + const now = Date.UTC(2026, 7, 9, 12, 0, 0); assert(formatRelativeTime("zh-CN", now - 5 * 60_000, now).includes("5"), "zh relative"); assert(formatRelativeTime("en", now - 5 * 60_000, now).includes("5"), "en relative"); +assert( + formatRelativeTime("en", now - 10_000, now).toLowerCase().includes("now") || + formatRelativeTime("en", now - 10_000, now).includes("second"), + "en just now" +); console.log("ALL I18N TESTS PASSED"); diff --git a/tests/projects-test.ts b/tests/projects-test.ts index 0c81476..80c7cd5 100644 --- a/tests/projects-test.ts +++ b/tests/projects-test.ts @@ -1,4 +1,4 @@ -import { formatRelativeTime } from "../lib/projects"; +import { formatRelativeTime } from "../lib/i18n"; function assert(cond: boolean, msg: string) { if (!cond) throw new Error(msg); @@ -6,9 +6,10 @@ function assert(cond: boolean, msg: string) { const now = Date.parse("2026-07-27T12:00:00Z"); -assert(formatRelativeTime(now - 10_000, now) === "just now", "just now"); -assert(formatRelativeTime(now - 5 * 60_000, now) === "5m ago", "minutes"); -assert(formatRelativeTime(now - 3 * 3600_000, now) === "3h ago", "hours"); -assert(formatRelativeTime(now - 3 * 86400_000, now) === "3d ago", "days"); +const justNow = formatRelativeTime("en", now - 10_000, now).toLowerCase(); +assert(justNow.includes("now") || justNow.includes("second"), "just now"); +assert(formatRelativeTime("en", now - 5 * 60_000, now).includes("5"), "minutes"); +assert(formatRelativeTime("en", now - 3 * 3600_000, now).includes("3"), "hours"); +assert(formatRelativeTime("en", now - 3 * 86400_000, now).includes("3"), "days"); console.log("ALL PROJECT HELPER TESTS PASSED"); diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 8da612f..4428d70 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -34,6 +34,7 @@ import { transformersModel, transformersProgress, } from "weightlift/transformers"; +import { en } from "@/lib/i18n/messages/en"; import type { Word, WorkerRequest, WorkerResponse } from "@/lib/types"; import { MODELS, @@ -458,8 +459,8 @@ models.subscribe((snap) => { type: "progress", message: rec.fromCache === true - ? "Loading speech model from cache…" - : "Downloading speech model…", + ? en["progress.loadingSpeechCache"] + : en["progress.downloadingSpeech"], value: rec.indeterminate ? null : rec.percent, }); }); @@ -582,7 +583,7 @@ async function fallbackAsrToWasm() { await models.unloadAll(); post({ type: "progress", - message: "GPU interrupted — continuing on CPU…", + message: en["progress.gpuFallback"], value: null, }); } @@ -674,7 +675,7 @@ async function speechFramesWithSilero( out[f] = Number(output.data[0] ?? 0) >= threshold; if (f > 0 && f % 512 === 0) { - post({ type: "progress", message: "Detecting speech…", value: f / n }); + post({ type: "progress", message: en["progress.detectingSpeech"], value: f / n }); } } return out; @@ -891,8 +892,8 @@ async function forceAlign( type: "progress", message: rec.fromCache === true - ? "Loading alignment model from cache…" - : "Downloading alignment model…", + ? en["progress.loadingAlignCache"] + : en["progress.downloadingAlign"], value: rec.indeterminate ? null : rec.percent, }); }; @@ -907,7 +908,7 @@ async function forceAlign( unsubscribe(); } // Switch off the download label as soon as the weights are in hand. - post({ type: "progress", message: "Aligning words…", value: 0 }); + post({ type: "progress", message: en["progress.aligning"], value: 0 }); const batches = groupWordsForAlignment(words, ALIGN_BATCH_MAX_S); const out: Word[] = []; let done = 0; @@ -935,7 +936,7 @@ async function forceAlign( } out.push(...(aligned ?? batch)); done++; - post({ type: "progress", message: "Aligning words…", value: done / batches.length }); + post({ type: "progress", message: en["progress.aligning"], value: done / batches.length }); } return out; } @@ -1011,7 +1012,7 @@ async function diarize(audio: Float32Array): Promise { }); postLive({ type: "progress", - message: "Identifying speakers…", + message: en["progress.speakers"], value: (i + 1) / spans.length, }); } @@ -1110,7 +1111,7 @@ async function finishWithDiarization( audio: Float32Array ): Promise { try { - post({ type: "progress", message: "Identifying speakers…", value: 0 }); + post({ type: "progress", message: en["progress.speakers"], value: 0 }); const segments = await diarize(audio); assignSpeakers(words, segments); } catch (err) { @@ -1132,11 +1133,11 @@ async function runParakeet( const [loaded, vad] = await Promise.all([getParakeet(), getVad()]); let model = loaded; - post({ type: "progress", message: "Detecting speech…", value: 0 }); + post({ type: "progress", message: en["progress.detectingSpeech"], value: 0 }); const { segments: speechSegments, frames: speechFrames } = await detectSpeechSegments(audio, vad); - post({ type: "progress", message: "Transcribing…", value: 0 }); + post({ type: "progress", message: en["progress.transcribing"], value: 0 }); const speechSamples = speechSegments.reduce( (n, s) => n + (s.endSample - s.startSample), 0 @@ -1188,7 +1189,7 @@ async function runParakeet( speechDone += segmentSamples; const value = speechSamples > 0 ? Math.min(1, speechDone / speechSamples) : 1; - postLive({ type: "progress", message: "Transcribing…", value }); + postLive({ type: "progress", message: en["progress.transcribing"], value }); } await releaseAsr("parakeet"); @@ -1219,7 +1220,7 @@ async function runWhisper( const [asr, vad] = await Promise.all([getAsr(choice), getVad()]); let transcriber = asr; - post({ type: "progress", message: "Detecting speech…", value: 0 }); + post({ type: "progress", message: en["progress.detectingSpeech"], value: 0 }); const { segments: speechSegments, frames: speechFrames } = await detectSpeechSegments(audio, vad); @@ -1228,7 +1229,7 @@ async function runWhisper( 0 ); - post({ type: "progress", message: "Transcribing…", value: 0 }); + post({ type: "progress", message: en["progress.transcribing"], value: 0 }); let partial = ""; // Use 29s instead of 30: transformers.js has a known word-timestamp bug @@ -1264,7 +1265,7 @@ async function runWhisper( chunkFloor = next; chunkTokens = 0; transcribed = next; - postLive({ type: "progress", message: "Transcribing…", value: transcribed }); + postLive({ type: "progress", message: en["progress.transcribing"], value: transcribed }); }; /** Nudge the bar forward between chunk boundaries as tokens stream in. */ @@ -1276,7 +1277,7 @@ async function runWhisper( const interpolated = Math.min(0.999, chunkFloor + frac * avgChunkDelta); if (interpolated > transcribed) { transcribed = interpolated; - postLive({ type: "progress", message: "Transcribing…", value: transcribed }); + postLive({ type: "progress", message: en["progress.transcribing"], value: transcribed }); } }; @@ -1435,10 +1436,7 @@ self.onmessage = async (event: MessageEvent) => { // resumes rather than starting the gigabyte over. post({ type: "error", - message: - "Couldn't finish downloading the speech model — the connection " + - "dropped. Check your internet and try again; the parts that " + - "finished downloading are kept.", + message: en["error.modelDownload"], cause: "network", }); return; @@ -1446,7 +1444,7 @@ self.onmessage = async (event: MessageEvent) => { post({ type: "error", message: isWebGpuDeviceLostError(err) - ? "Transcription was interrupted when the GPU reset (often after locking the screen). Please try again." + ? en["error.gpuReset"] : err instanceof Error ? err.message : "Transcription failed.", From a28641c6c232de7d7d07ef0653ff189acbb92539 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:35:37 +0000 Subject: [PATCH 2/4] Add Electron locale catalogs Co-authored-by: Wassim Gharbi --- electron/locale/de.ts | 43 ++++++++++++++++++++++++++++++++++++++++ electron/locale/es.ts | 43 ++++++++++++++++++++++++++++++++++++++++ electron/locale/fr.ts | 43 ++++++++++++++++++++++++++++++++++++++++ electron/locale/ja.ts | 43 ++++++++++++++++++++++++++++++++++++++++ electron/locale/ko.ts | 43 ++++++++++++++++++++++++++++++++++++++++ electron/locale/zh-TW.ts | 43 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+) create mode 100644 electron/locale/de.ts create mode 100644 electron/locale/es.ts create mode 100644 electron/locale/fr.ts create mode 100644 electron/locale/ja.ts create mode 100644 electron/locale/ko.ts create mode 100644 electron/locale/zh-TW.ts diff --git a/electron/locale/de.ts b/electron/locale/de.ts new file mode 100644 index 0000000..77a77bc --- /dev/null +++ b/electron/locale/de.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const de: Record = { + file: "Datei", + openProject: "Projekt öffnen…", + reopenLast: "Letztes Projekt erneut öffnen", + recentProjects: "Zuletzt verwendete Projekte", + noRecent: "Keine zuletzt verwendeten Projekte", + clearRecent: "Zuletzt verwendete Projekte löschen", + edit: "Bearbeiten", + undo: "Rückgängig", + redo: "Wiederholen", + cut: "Ausschneiden", + copy: "Kopieren", + paste: "Einfügen", + pasteMatch: "Einfügen und Stil anpassen", + delete: "Löschen", + selectAll: "Alles auswählen", + view: "Ansicht", + reload: "Neu laden", + forceReload: "Neu laden erzwingen", + devTools: "Entwicklertools ein-/ausblenden", + resetZoom: "Originalgröße", + zoomIn: "Vergrößern", + zoomOut: "Verkleinern", + fullscreen: "Vollbild ein-/ausschalten", + window: "Fenster", + minimize: "Minimieren", + zoom: "Zoomen", + front: "Alle nach vorne bringen", + close: "Fenster schließen", + quit: "Rescript beenden", + about: "Über Rescript", + services: "Dienste", + hide: "Rescript ausblenden", + hideOthers: "Andere ausblenden", + unhide: "Alle einblenden", + restart: "Neu starten", + later: "Später", + updateTitle: "Update verfügbar", + updateMessage: "Rescript {version} ist bereit zur Installation.", + updateDetail: "Starten Sie jetzt neu, um das Update anzuwenden.", +}; diff --git a/electron/locale/es.ts b/electron/locale/es.ts new file mode 100644 index 0000000..250123b --- /dev/null +++ b/electron/locale/es.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const es: Record = { + file: "Archivo", + openProject: "Abrir proyecto…", + reopenLast: "Volver a abrir el último proyecto", + recentProjects: "Proyectos recientes", + noRecent: "No hay proyectos recientes", + clearRecent: "Borrar proyectos recientes", + edit: "Edición", + undo: "Deshacer", + redo: "Rehacer", + cut: "Cortar", + copy: "Copiar", + paste: "Pegar", + pasteMatch: "Pegar con el mismo estilo", + delete: "Eliminar", + selectAll: "Seleccionar todo", + view: "Ver", + reload: "Recargar", + forceReload: "Forzar recarga", + devTools: "Alternar herramientas de desarrollo", + resetZoom: "Tamaño real", + zoomIn: "Acercar", + zoomOut: "Alejar", + fullscreen: "Alternar pantalla completa", + window: "Ventana", + minimize: "Minimizar", + zoom: "Zoom", + front: "Traer todo al frente", + close: "Cerrar ventana", + quit: "Salir de Rescript", + about: "Acerca de Rescript", + services: "Servicios", + hide: "Ocultar Rescript", + hideOthers: "Ocultar otros", + unhide: "Mostrar todo", + restart: "Reiniciar", + later: "Más tarde", + updateTitle: "Actualización disponible", + updateMessage: "Rescript {version} está listo para instalarse.", + updateDetail: "Reinicia ahora para aplicar la actualización.", +}; diff --git a/electron/locale/fr.ts b/electron/locale/fr.ts new file mode 100644 index 0000000..0bfe86a --- /dev/null +++ b/electron/locale/fr.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const fr: Record = { + file: "Fichier", + openProject: "Ouvrir un projet…", + reopenLast: "Rouvrir le dernier projet", + recentProjects: "Projets récents", + noRecent: "Aucun projet récent", + clearRecent: "Effacer les projets récents", + edit: "Édition", + undo: "Annuler", + redo: "Rétablir", + cut: "Couper", + copy: "Copier", + paste: "Coller", + pasteMatch: "Coller et adapter le style", + delete: "Supprimer", + selectAll: "Tout sélectionner", + view: "Affichage", + reload: "Recharger", + forceReload: "Forcer le rechargement", + devTools: "Afficher/masquer les outils de développement", + resetZoom: "Taille réelle", + zoomIn: "Agrandir", + zoomOut: "Réduire", + fullscreen: "Activer/désactiver le plein écran", + window: "Fenêtre", + minimize: "Réduire", + zoom: "Zoom", + front: "Tout ramener au premier plan", + close: "Fermer la fenêtre", + quit: "Quitter Rescript", + about: "À propos de Rescript", + services: "Services", + hide: "Masquer Rescript", + hideOthers: "Masquer les autres", + unhide: "Tout afficher", + restart: "Redémarrer", + later: "Plus tard", + updateTitle: "Mise à jour disponible", + updateMessage: "Rescript {version} est prêt à être installé.", + updateDetail: "Redémarrez maintenant pour appliquer la mise à jour.", +}; diff --git a/electron/locale/ja.ts b/electron/locale/ja.ts new file mode 100644 index 0000000..52d5f4b --- /dev/null +++ b/electron/locale/ja.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const ja: Record = { + file: "ファイル", + openProject: "プロジェクトを開く…", + reopenLast: "最後のプロジェクトを再度開く", + recentProjects: "最近使ったプロジェクト", + noRecent: "最近使ったプロジェクトはありません", + clearRecent: "最近使ったプロジェクトを消去", + edit: "編集", + undo: "取り消す", + redo: "やり直す", + cut: "切り取り", + copy: "コピー", + paste: "ペースト", + pasteMatch: "ペーストしてスタイルを合わせる", + delete: "削除", + selectAll: "すべてを選択", + view: "表示", + reload: "再読み込み", + forceReload: "強制再読み込み", + devTools: "開発者ツールを切り替える", + resetZoom: "実際のサイズ", + zoomIn: "拡大", + zoomOut: "縮小", + fullscreen: "フルスクリーンを切り替える", + window: "ウインドウ", + minimize: "しまう", + zoom: "拡大/縮小", + front: "すべてを手前に移動", + close: "ウインドウを閉じる", + quit: "Rescriptを終了", + about: "Rescriptについて", + services: "サービス", + hide: "Rescriptを隠す", + hideOthers: "ほかを隠す", + unhide: "すべてを表示", + restart: "再起動", + later: "後で", + updateTitle: "アップデートがあります", + updateMessage: "Rescript {version} をインストールする準備ができました。", + updateDetail: "アップデートを適用するには今すぐ再起動してください。", +}; diff --git a/electron/locale/ko.ts b/electron/locale/ko.ts new file mode 100644 index 0000000..2e45807 --- /dev/null +++ b/electron/locale/ko.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const ko: Record = { + file: "파일", + openProject: "프로젝트 열기…", + reopenLast: "마지막 프로젝트 다시 열기", + recentProjects: "최근 프로젝트", + noRecent: "최근 프로젝트 없음", + clearRecent: "최근 프로젝트 지우기", + edit: "편집", + undo: "실행 취소", + redo: "다시 실행", + cut: "오려두기", + copy: "복사", + paste: "붙여넣기", + pasteMatch: "스타일에 맞게 붙여넣기", + delete: "삭제", + selectAll: "모두 선택", + view: "보기", + reload: "새로고침", + forceReload: "강제로 새로고침", + devTools: "개발자 도구 전환", + resetZoom: "실제 크기", + zoomIn: "확대", + zoomOut: "축소", + fullscreen: "전체 화면 전환", + window: "윈도우", + minimize: "최소화", + zoom: "확대/축소", + front: "모두 앞으로 가져오기", + close: "윈도우 닫기", + quit: "Rescript 종료", + about: "Rescript 정보", + services: "서비스", + hide: "Rescript 가리기", + hideOthers: "다른 항목 가리기", + unhide: "모두 보기", + restart: "다시 시작", + later: "나중에", + updateTitle: "업데이트 사용 가능", + updateMessage: "Rescript {version}을 설치할 준비가 되었습니다.", + updateDetail: "업데이트를 적용하려면 지금 다시 시작하세요.", +}; diff --git a/electron/locale/zh-TW.ts b/electron/locale/zh-TW.ts new file mode 100644 index 0000000..037d991 --- /dev/null +++ b/electron/locale/zh-TW.ts @@ -0,0 +1,43 @@ +import type { DesktopMessageKey } from "./en"; + +export const zhTW: Record = { + file: "檔案", + openProject: "開啟專案…", + reopenLast: "重新開啟上一個專案", + recentProjects: "最近使用的專案", + noRecent: "沒有最近使用的專案", + clearRecent: "清除最近使用的專案", + edit: "編輯", + undo: "還原", + redo: "重做", + cut: "剪下", + copy: "複製", + paste: "貼上", + pasteMatch: "貼上並符合樣式", + delete: "刪除", + selectAll: "全選", + view: "顯示", + reload: "重新載入", + forceReload: "強制重新載入", + devTools: "切換開發者工具", + resetZoom: "實際大小", + zoomIn: "放大", + zoomOut: "縮小", + fullscreen: "切換全螢幕", + window: "視窗", + minimize: "最小化", + zoom: "縮放", + front: "全部移到最前", + close: "關閉視窗", + quit: "結束 Rescript", + about: "關於 Rescript", + services: "服務", + hide: "隱藏 Rescript", + hideOthers: "隱藏其他", + unhide: "全部顯示", + restart: "重新啟動", + later: "稍後", + updateTitle: "有可用的更新", + updateMessage: "Rescript {version} 已準備好安裝。", + updateDetail: "立即重新啟動以套用更新。", +}; From c13e0c4afdfb232ecc423db6302133a15a3dc815 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:37:29 +0000 Subject: [PATCH 3/4] feat(i18n): add additional UI message catalogs Co-authored-by: Wassim Gharbi --- lib/i18n/messages/de.ts | 204 +++++++++++++++++++++++++++++++++++++ lib/i18n/messages/es.ts | 204 +++++++++++++++++++++++++++++++++++++ lib/i18n/messages/fr.ts | 204 +++++++++++++++++++++++++++++++++++++ lib/i18n/messages/ja.ts | 204 +++++++++++++++++++++++++++++++++++++ lib/i18n/messages/ko.ts | 204 +++++++++++++++++++++++++++++++++++++ lib/i18n/messages/zh-TW.ts | 204 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1224 insertions(+) create mode 100644 lib/i18n/messages/de.ts create mode 100644 lib/i18n/messages/es.ts create mode 100644 lib/i18n/messages/fr.ts create mode 100644 lib/i18n/messages/ja.ts create mode 100644 lib/i18n/messages/ko.ts create mode 100644 lib/i18n/messages/zh-TW.ts diff --git a/lib/i18n/messages/de.ts b/lib/i18n/messages/de.ts new file mode 100644 index 0000000..3943612 --- /dev/null +++ b/lib/i18n/messages/de.ts @@ -0,0 +1,204 @@ +import type { MessageKey } from "./en"; + +/** German UI catalog. Every key in {@link en} must be present. */ +export const de: Record = { + "app.title": "Rescript — Videos bearbeiten wie Text", + "common.cancel": "Abbrechen", + "common.close": "Schließen", + "common.delete": "Löschen", + "common.download": "Herunterladen", + "common.import": "Importieren", + "common.loading": "Wird geladen", + "common.remove": "Entfernen", + "common.restore": "Wiederherstellen", + "common.retry": "Erneut versuchen", + "common.searchOrCreate": "Suchen oder erstellen…", + "common.settings": "Einstellungen", + "common.system": "System", + "common.tools": "Werkzeuge", + "settings.appearance": "Darstellung", + "settings.light": "Hell", + "settings.dark": "Dunkel", + "settings.interfaceLanguage": "Sprache der Oberfläche", + "settings.privacy": "Datenschutz", + "settings.helpImprove": "Hilf mit, die App zu verbessern", + "settings.telemetryHelp": "Anonyme Statistiken zur Funktionsnutzung und Absturzberichte senden.", + "settings.support": "Support / Feedback", + "settings.reportIssue": "Problem melden", + "settings.homepage": "Homepage", + "settings.github": "GitHub", + "settings.followX": "Auf X folgen", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "Transkriptquelle", + "model.language": "Sprache", + "model.transcriptLanguage": "Transkriptsprache", + "model.importTranscript": "Transkript importieren", + "upload.recentProjects": "Letzte Projekte", + "upload.removeRecent": "Aus letzten Projekten entfernen", + "upload.dropPrefix": "Lege hier eine Video- oder Audiodatei ab, oder", + "upload.browse": "durchsuchen", + "upload.chooseTranscriptFirst": "Wähle oben im Menü ein Transkript und lege dann deine Medien ab", + "upload.willUseTranscript": "{name} wird verwendet · MP4, WebM, MOV, MP3, WAV, …", + "upload.mediaFormats": "MP4, WebM, MOV, MP3, WAV, M4A, …", + "upload.gettingReady": "Alles wird vorbereitet", + "upload.gettingReadyHelp": "Die Medien-Engine wird eingerichtet. Das passiert nur einmal.", + "upload.unsupported": "Dieser Browser kann den Editor nicht ausführen", + "upload.unsupportedHelp": + "Die Bearbeitung benötigt SharedArrayBuffer, dafür muss die Seite Cross-Origin-isoliert sein. Probiere eine aktuelle Version von Chrome, Edge, Safari oder Firefox über HTTPS.", + "upload.transcribeTitle": "Transkribieren", + "upload.transcribeText": "Whisper lokal nutzen oder SRT / VTT importieren.", + "upload.editTitle": "Bearbeiten", + "upload.editText": "Wörter auswählen und Delete drücken, um zu schneiden.", + "upload.exportTitle": "Exportieren", + "upload.exportText": "Den finalen Schnitt als MP4 oder M4A rendern.", + "editor.chooseTranscript": "Wähle zuerst eine Transkriptdatei im Quellenmenü.", + "editor.chooseMedia": "Bitte wähle eine Video- oder Audiodatei.", + "editor.undo": "Rückgängig (⌘Z)", + "editor.redo": "Wiederholen (⇧⌘Z)", + "editor.export": "Exportieren", + "topbar.startOver": "Neu anfangen", + "transcript.replace": "Transkript aus SRT, VTT oder JSON ersetzen", + "transcript.header": "Transkript", + "transcript.wordDeleted": "{count} Wort", + "transcript.wordsDeleted": "{count} Wörter", + "transcript.replaceConfirm": "Aktuelles Transkript durch diese Datei ersetzen?", + "transcript.invalidFile": "Wähle eine SRT-, VTT- oder JSON-Transkriptdatei.", + "transcript.noSpeech": "Keine Sprache erkannt. Stelle sicher, dass diese Datei Audio enthält, oder importiere ein Transkript.", + "transcript.cut": "Schneiden", + "transcript.follow": "Abspielposition folgen", + "transcript.hideDeleted": "Gelöschte Wörter ausblenden", + "transcript.showDeleted": "Gelöschte Wörter anzeigen", + "transcript.correct": "Korrigieren", + "transcript.scrollWithPlayhead": "Mit der Abspielposition scrollen", + "transcript.joinSplit": "Clip geteilt — klicken, um diese Clips zu verbinden", + "transcript.joinClips": "Clips verbinden", + "transcript.hesitation": "Zögern erkannt (nicht transkribiert) — mit Füllwörter entfernen schneiden", + "tools.bulk": "Transkript gesammelt bereinigen", + "tools.removeFillers": "Füllwörter entfernen", + "tools.removeFillersTitle": "Füllwörter (\"ähm\", \"äh\", \"...\", …) aus dem Video schneiden", + "tools.restoreFillers": "Füllwörter wiederherstellen", + "tools.restoreFillersTitle": "Alle geschnittenen Füllwörter zurückholen", + "tools.removeSilences": "Stille entfernen", + "tools.removeSilencesTitle": "Pausen und Stille (≥{seconds} s) aus dem Video schneiden", + "tools.restoreSilences": "Stille wiederherstellen", + "tools.restoreSilencesTitle": "Alle geschnittenen Pausen zurückholen", + "timeline.removed": "{trimmed} entfernt — ursprüngliche Länge {duration}", + "timeline.back5": "5 s zurück", + "timeline.playPause": "Wiedergabe / Pause (Leertaste)", + "timeline.forward5": "5 s vor", + "timeline.split": "Teilen", + "timeline.splitTitle": "Clip an der Abspielposition teilen (S)", + "timeline.splitDisabled": "Bewege die Abspielposition auf einen behaltenen Bereich, um zu teilen", + "timeline.delete": "Löschen", + "timeline.deleteTitle": "Ausgewählten Clip löschen (Delete)", + "timeline.deleteDisabled": "Wähle einen Clip in der Timeline aus, um ihn zu löschen", + "timeline.restore": "Wiederherstellen", + "timeline.restoreTitle": "Ausgewählten Schnitt wiederherstellen (Delete)", + "timeline.restoreDisabled": "Wähle einen Schnitt oder Stille-Abschnitt in der Timeline aus, um ihn wiederherzustellen", + "timeline.zoomOut": "Verkleinern", + "timeline.fit": "An Fenster anpassen", + "timeline.zoomIn": "Vergrößern — Wortkanten ziehen, um Timing zu verfeinern", + "timeline.joinClips": "Diese Clips verbinden (Teilung entfernen)", + "timeline.trimStart": "Clip-Anfang trimmen", + "timeline.trimEnd": "Clip-Ende trimmen", + "timeline.hesitationAdjust": "… Zögern erkannt — Kanten ziehen zum Anpassen oder mit Füllwörter entfernen schneiden", + "timeline.hesitationCut": "… Zögern erkannt — mit Füllwörter entfernen schneiden", + "timeline.dragTiming": "{word} — Kanten ziehen, um Timing anzupassen", + "timeline.scrollZoom": "Scrollen zum Vergrößern / Verkleinern", + "speaker.moveStart": "Ziehen, um zu verschieben, wo dieser Sprecher beginnt", + "speaker.moveLabel": "Sprecherlabel verschieben", + "speaker.options": "Sprecheroptionen", + "speaker.change": "Sprecher ändern", + "speaker.rename": "Sprecher umbenennen", + "speaker.renameAction": "Umbenennen", + "speaker.replace": "Im Projekt ersetzen durch…", + "speaker.removeProject": "Aus Projekt entfernen", + "speaker.newName": "Neuer Name", + "speaker.find": "Sprecher suchen…", + "speaker.noMatches": "Keine passenden Sprecher", + "speaker.renameTo": "{current} in {next} umbenennen", + "speaker.button": "Sprecher", + "speaker.search": "Suchen oder erstellen…", + "speaker.defaultName": "Sprecher {number}", + "speaker.create": "\"{name}\" erstellen", + "export.title": "Exportieren", + "export.type": "Exporttyp", + "export.video": "Video", + "export.audio": "Audio", + "export.transcript": "Transkript", + "export.subtitles": "Untertitel", + "export.videoUnavailable": "Videoexport ist für reine Audioprojekte nicht verfügbar", + "export.noAudio": "Diese Datei hat keine Audiospur", + "export.noWordsFirst": "Zuerst transkribieren oder ein Transkript importieren", + "export.format": "Format", + "export.resolution": "Auflösung", + "export.original": "Original", + "export.plainText": "Nur Text", + "export.statOriginal": "Original", + "export.statCuts": "Schnitte", + "export.statEdited": "Bearbeitet", + "export.transcriptHelp": "Text mit Sprecherlabels und entfernten Schnitten. Keine Zeitstempel.", + "export.subtitlesHelp": + "SRT und VTT verwenden die bearbeitete Timeline (Schnitte angewendet). JSON behält die vollständige Wortliste für den erneuten Import.", + "export.encodingHelp": "Neucodierung mit ffmpeg.wasm — längere Dateien dauern eine Weile.", + "export.reexport": "Mit neuesten Änderungen erneut exportieren", + "export.rendering": "Rendering in deinem Browser…", + "export.downloadFile": "{name} herunterladen", + "export.downloadFormat": ".{format} herunterladen", + "export.exportFormat": "{format} exportieren", + "import.reading": "Transkript wird gelesen…", + "import.importing": "Import läuft…", + "import.failed": "Import fehlgeschlagen", + "import.readingFile": "Datei wird gelesen…", + "import.chooseFileShort": "Datei wählen…", + "import.chooseFile": "SRT-, VTT- oder JSON-Transkript wählen", + "import.selected": "{name} ausgewählt", + "banner.faster": "Schnellere Transkription und Exporte?", + "banner.getDesktop": "Rescript Desktop-App holen", + "banner.downloadFor": "Für {platform} herunterladen", + "banner.dismiss": "Ausblenden", + "social.githubRepo": "GitHub-Repository", + "social.discordServer": "Discord-Server", + "social.xProfile": "X-Profil", + "globalError.title": "Rescript — etwas ist schiefgelaufen", + "globalError.heading": "Etwas ist schiefgelaufen", + "globalError.body": + "Im Editor ist ein unerwarteter Fehler aufgetreten. Deine gespeicherten Projekte sind noch auf diesem Gerät — nach dem Neuladen sollten sie wieder da sein.", + "progress.loadingMedia": "Medien werden geladen…", + "progress.loadingMediaEngine": "Medien-Engine wird geladen…", + "progress.extractingAudio": "Audio wird extrahiert…", + "progress.loadingSpeechModel": "Sprachmodell wird geladen…", + "progress.loadingSpeechCache": "Sprachmodell wird aus Cache geladen…", + "progress.downloadingSpeech": "Sprachmodell wird heruntergeladen…", + "progress.gpuFallback": "GPU unterbrochen — Fortsetzung auf CPU…", + "progress.detectingSpeech": "Sprache wird erkannt…", + "progress.transcribing": "Transkription läuft…", + "progress.loadingAlignCache": "Alignment-Modell wird aus Cache geladen…", + "progress.downloadingAlign": "Alignment-Modell wird heruntergeladen…", + "progress.aligning": "Wörter werden ausgerichtet…", + "progress.speakers": "Sprecher werden erkannt…", + "error.selectModel": "Wähle ein Sprachmodell zum Transkribieren aus.", + "error.workerCrashed": "Transkriptions-Worker ist abgestürzt.", + "error.mediaEngineNetwork": "Die Medien-Engine konnte nicht geladen werden — die Verbindung wurde unterbrochen. Prüfe deine Internetverbindung und versuche es erneut.", + "error.processFile": "Diese Datei konnte nicht verarbeitet werden.", + "error.extractAudio": "Audio konnte aus dieser Datei nicht extrahiert werden.", + "error.nothingToExport": "Alles wurde gelöscht — nichts zu exportieren.", + "error.videoExport": "Export beim Rendern des Videos fehlgeschlagen.", + "error.audioExport": "Export beim Rendern des Audios fehlgeschlagen.", + "error.export": "Export fehlgeschlagen.", + "error.emptyTranscript": "Diese Transkriptdatei ist leer.", + "error.noTimedWords": "Keine Wörter mit Zeitangaben in diesem Transkript gefunden.", + "error.parseJson": "Dieses JSON-Transkript konnte nicht geparst werden.", + "error.jsonShape": 'JSON muss ein Wortarray oder { "words": [...] } sein.', + "error.noWords": "Keine Wörter zum Exportieren.", + "error.projectMissing": "Dieses Projekt ist nicht mehr gespeichert.", + "error.openProject": "Dieses Projekt konnte nicht geöffnet werden.", + "error.removeProject": "Dieses Projekt konnte nicht entfernt werden.", + "error.readTranscript": "Dieses Transkript konnte nicht gelesen werden.", + "error.clearRecent": "Letzte Projekte konnten nicht gelöscht werden.", + "error.modelDownload": + "Der Download des Sprachmodells konnte nicht abgeschlossen werden — die Verbindung wurde unterbrochen. Prüfe deine Internetverbindung und versuche es erneut; bereits heruntergeladene Teile bleiben erhalten.", + "error.gpuReset": "Die Transkription wurde unterbrochen, als die GPU zurückgesetzt wurde (oft nach dem Sperren des Bildschirms). Bitte versuche es erneut.", + "confirm.clearRecent": "Alle letzten Projekte entfernen? Ihre gespeicherten Bearbeitungen werden gelöscht.", +}; diff --git a/lib/i18n/messages/es.ts b/lib/i18n/messages/es.ts new file mode 100644 index 0000000..7e8116f --- /dev/null +++ b/lib/i18n/messages/es.ts @@ -0,0 +1,204 @@ +import type { MessageKey } from "./en"; + +/** Spanish UI catalog. Every key in {@link en} must be present. */ +export const es: Record = { + "app.title": "Rescript — edita videos como editas texto", + "common.cancel": "Cancelar", + "common.close": "Cerrar", + "common.delete": "Eliminar", + "common.download": "Descargar", + "common.import": "Importar", + "common.loading": "Cargando", + "common.remove": "Quitar", + "common.restore": "Restaurar", + "common.retry": "Intentar de nuevo", + "common.searchOrCreate": "Buscar o crear…", + "common.settings": "Ajustes", + "common.system": "Sistema", + "common.tools": "Herramientas", + "settings.appearance": "Apariencia", + "settings.light": "Claro", + "settings.dark": "Oscuro", + "settings.interfaceLanguage": "Idioma de la interfaz", + "settings.privacy": "Privacidad", + "settings.helpImprove": "Ayuda a mejorar la app", + "settings.telemetryHelp": "Envía estadísticas anónimas de uso de funciones e informes de fallos.", + "settings.support": "Soporte / comentarios", + "settings.reportIssue": "Informar de un problema", + "settings.homepage": "Página de inicio", + "settings.github": "GitHub", + "settings.followX": "Seguir en X", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "Origen de la transcripción", + "model.language": "Idioma", + "model.transcriptLanguage": "Idioma de transcripción", + "model.importTranscript": "Importar transcripción", + "upload.recentProjects": "Proyectos recientes", + "upload.removeRecent": "Quitar de recientes", + "upload.dropPrefix": "Suelta un archivo de video o audio aquí, o", + "upload.browse": "explora", + "upload.chooseTranscriptFirst": "Elige una transcripción en el menú de arriba y luego suelta el medio", + "upload.willUseTranscript": "Se usará {name} · MP4, WebM, MOV, MP3, WAV, …", + "upload.mediaFormats": "MP4, WebM, MOV, MP3, WAV, M4A, …", + "upload.gettingReady": "Preparando todo", + "upload.gettingReadyHelp": "Configurando el motor multimedia; esto solo ocurre una vez.", + "upload.unsupported": "Este navegador no puede ejecutar el editor", + "upload.unsupportedHelp": + "La edición necesita SharedArrayBuffer, que requiere una página aislada entre orígenes. Prueba una versión reciente de Chrome, Edge, Safari o Firefox mediante HTTPS.", + "upload.transcribeTitle": "Transcribir", + "upload.transcribeText": "Whisper localmente, o importa SRT / VTT.", + "upload.editTitle": "Editar", + "upload.editText": "Selecciona palabras y pulsa Suprimir para editar.", + "upload.exportTitle": "Exportar", + "upload.exportText": "Renderiza el corte final a MP4 o M4A.", + "editor.chooseTranscript": "Primero elige un archivo de transcripción desde el menú de origen.", + "editor.chooseMedia": "Elige un archivo de video o audio.", + "editor.undo": "Deshacer (⌘Z)", + "editor.redo": "Rehacer (⇧⌘Z)", + "editor.export": "Exportar", + "topbar.startOver": "Empezar de nuevo", + "transcript.replace": "Reemplazar transcripción desde SRT, VTT o JSON", + "transcript.header": "Transcripción", + "transcript.wordDeleted": "{count} palabra", + "transcript.wordsDeleted": "{count} palabras", + "transcript.replaceConfirm": "¿Reemplazar la transcripción actual con este archivo?", + "transcript.invalidFile": "Elige un archivo de transcripción SRT, VTT o JSON.", + "transcript.noSpeech": "No se detectó voz. Asegúrate de que este archivo tenga audio, o importa una transcripción.", + "transcript.cut": "Cortar", + "transcript.follow": "Seguir cabezal", + "transcript.hideDeleted": "Ocultar palabras eliminadas", + "transcript.showDeleted": "Mostrar palabras eliminadas", + "transcript.correct": "Corregir", + "transcript.scrollWithPlayhead": "Desplazar con el cabezal", + "transcript.joinSplit": "Clip dividido — haz clic para unir estos clips", + "transcript.joinClips": "Unir clips", + "transcript.hesitation": "Vacilación detectada (no transcrita) — córtala con Quitar muletillas", + "tools.bulk": "Limpiezas masivas de transcripción", + "tools.removeFillers": "Quitar muletillas", + "tools.removeFillersTitle": "Corta muletillas (\"eh\", \"mmm\", \"...\", …) del video", + "tools.restoreFillers": "Restaurar muletillas", + "tools.restoreFillersTitle": "Devuelve cada muletilla cortada", + "tools.removeSilences": "Quitar silencios", + "tools.removeSilencesTitle": "Corta pausas y silencios (≥{seconds} s) del video", + "tools.restoreSilences": "Restaurar silencios", + "tools.restoreSilencesTitle": "Devuelve cada pausa cortada", + "timeline.removed": "{trimmed} quitado — duración original {duration}", + "timeline.back5": "Retroceder 5 s", + "timeline.playPause": "Reproducir / pausar (espacio)", + "timeline.forward5": "Avanzar 5 s", + "timeline.split": "Dividir", + "timeline.splitTitle": "Dividir clip en el cabezal (S)", + "timeline.splitDisabled": "Mueve el cabezal a una región conservada para dividir", + "timeline.delete": "Eliminar", + "timeline.deleteTitle": "Eliminar clip seleccionado (Suprimir)", + "timeline.deleteDisabled": "Selecciona un clip en la línea de tiempo para eliminarlo", + "timeline.restore": "Restaurar", + "timeline.restoreTitle": "Restaurar corte seleccionado (Suprimir)", + "timeline.restoreDisabled": "Selecciona un corte o una sección de silencio en la línea de tiempo para restaurar", + "timeline.zoomOut": "Alejar", + "timeline.fit": "Ajustar a la ventana", + "timeline.zoomIn": "Acercar — arrastra los bordes de las palabras para ajustar el tiempo", + "timeline.joinClips": "Unir estos clips (quitar división)", + "timeline.trimStart": "Recortar inicio del clip", + "timeline.trimEnd": "Recortar final del clip", + "timeline.hesitationAdjust": "… vacilación detectada — arrastra los bordes para ajustar, o córtala con Quitar muletillas", + "timeline.hesitationCut": "… vacilación detectada — córtala con Quitar muletillas", + "timeline.dragTiming": "{word} — arrastra los bordes para ajustar el tiempo", + "timeline.scrollZoom": "Desplázate para acercar / alejar", + "speaker.moveStart": "Arrastra para mover dónde empieza este hablante", + "speaker.moveLabel": "Mover etiqueta del hablante", + "speaker.options": "Opciones del hablante", + "speaker.change": "Cambiar hablante", + "speaker.rename": "Renombrar hablante", + "speaker.renameAction": "Renombrar", + "speaker.replace": "Reemplazar en el proyecto por…", + "speaker.removeProject": "Quitar del proyecto", + "speaker.newName": "Nuevo nombre", + "speaker.find": "Buscar un hablante…", + "speaker.noMatches": "No hay hablantes coincidentes", + "speaker.renameTo": "Renombrar {current} a {next}", + "speaker.button": "Hablante", + "speaker.search": "Buscar o crear…", + "speaker.defaultName": "Hablante {number}", + "speaker.create": "Crear \"{name}\"", + "export.title": "Exportar", + "export.type": "Tipo de exportación", + "export.video": "Video", + "export.audio": "Audio", + "export.transcript": "Transcripción", + "export.subtitles": "Subtítulos", + "export.videoUnavailable": "La exportación de video no está disponible para proyectos solo de audio", + "export.noAudio": "Este archivo no tiene pista de audio", + "export.noWordsFirst": "Transcribe o importa una transcripción primero", + "export.format": "Formato", + "export.resolution": "Resolución", + "export.original": "Original", + "export.plainText": "Texto sin formato", + "export.statOriginal": "Original", + "export.statCuts": "Cortes", + "export.statEdited": "Editado", + "export.transcriptHelp": "Texto con etiquetas de hablante y cortes eliminados. Sin marcas de tiempo.", + "export.subtitlesHelp": + "SRT y VTT usan la línea de tiempo editada (con cortes aplicados). JSON conserva la lista completa de palabras para reimportar.", + "export.encodingHelp": "Recodificando con ffmpeg.wasm — los archivos largos tardan un poco.", + "export.reexport": "Reexportar con las últimas ediciones", + "export.rendering": "Renderizando en tu navegador…", + "export.downloadFile": "Descargar {name}", + "export.downloadFormat": "Descargar .{format}", + "export.exportFormat": "Exportar {format}", + "import.reading": "Leyendo transcripción…", + "import.importing": "Importando…", + "import.failed": "Error al importar", + "import.readingFile": "Leyendo archivo…", + "import.chooseFileShort": "Elige un archivo…", + "import.chooseFile": "Elige una transcripción SRT, VTT o JSON", + "import.selected": "{name} seleccionado", + "banner.faster": "¿Quieres transcripciones y exportaciones más rápidas?", + "banner.getDesktop": "Obtén la app de escritorio de Rescript", + "banner.downloadFor": "Descargar para {platform}", + "banner.dismiss": "Descartar", + "social.githubRepo": "Repositorio de GitHub", + "social.discordServer": "Servidor de Discord", + "social.xProfile": "Perfil de X", + "globalError.title": "Rescript — algo salió mal", + "globalError.heading": "Algo salió mal", + "globalError.body": + "El editor encontró un error inesperado. Tus proyectos guardados siguen en este dispositivo; al recargar deberían volver.", + "progress.loadingMedia": "Cargando medios…", + "progress.loadingMediaEngine": "Cargando motor multimedia…", + "progress.extractingAudio": "Extrayendo audio…", + "progress.loadingSpeechModel": "Cargando modelo de voz…", + "progress.loadingSpeechCache": "Cargando modelo de voz desde la caché…", + "progress.downloadingSpeech": "Descargando modelo de voz…", + "progress.gpuFallback": "La GPU se interrumpió — continuando en CPU…", + "progress.detectingSpeech": "Detectando voz…", + "progress.transcribing": "Transcribiendo…", + "progress.loadingAlignCache": "Cargando modelo de alineación desde la caché…", + "progress.downloadingAlign": "Descargando modelo de alineación…", + "progress.aligning": "Alineando palabras…", + "progress.speakers": "Identificando hablantes…", + "error.selectModel": "Selecciona un modelo de voz para transcribir.", + "error.workerCrashed": "El worker de transcripción falló.", + "error.mediaEngineNetwork": "No se pudo cargar el motor multimedia — se cortó la conexión. Revisa tu internet e inténtalo de nuevo.", + "error.processFile": "No se pudo procesar este archivo.", + "error.extractAudio": "No se pudo extraer audio de este archivo.", + "error.nothingToExport": "Todo se ha eliminado — no hay nada que exportar.", + "error.videoExport": "La exportación falló al renderizar el video.", + "error.audioExport": "La exportación falló al renderizar el audio.", + "error.export": "Error al exportar.", + "error.emptyTranscript": "Ese archivo de transcripción está vacío.", + "error.noTimedWords": "No se encontraron palabras con tiempo en esa transcripción.", + "error.parseJson": "No se pudo analizar esa transcripción JSON.", + "error.jsonShape": 'JSON debe ser un array de palabras o { "words": [...] }.', + "error.noWords": "No hay palabras para exportar.", + "error.projectMissing": "Ese proyecto ya no está guardado.", + "error.openProject": "No se pudo abrir ese proyecto.", + "error.removeProject": "No se pudo quitar ese proyecto.", + "error.readTranscript": "No se pudo leer esa transcripción.", + "error.clearRecent": "No se pudieron borrar los proyectos recientes.", + "error.modelDownload": + "No se pudo terminar de descargar el modelo de voz — se cortó la conexión. Revisa tu internet e inténtalo de nuevo; las partes ya descargadas se conservan.", + "error.gpuReset": "La transcripción se interrumpió cuando se reinició la GPU (a menudo después de bloquear la pantalla). Inténtalo de nuevo.", + "confirm.clearRecent": "¿Quitar todos los proyectos recientes? Sus ediciones guardadas se eliminarán.", +}; diff --git a/lib/i18n/messages/fr.ts b/lib/i18n/messages/fr.ts new file mode 100644 index 0000000..efee275 --- /dev/null +++ b/lib/i18n/messages/fr.ts @@ -0,0 +1,204 @@ +import type { MessageKey } from "./en"; + +/** French UI catalog. Every key in {@link en} must be present. */ +export const fr: Record = { + "app.title": "Rescript — montez vos vidéos comme du texte", + "common.cancel": "Annuler", + "common.close": "Fermer", + "common.delete": "Supprimer", + "common.download": "Télécharger", + "common.import": "Importer", + "common.loading": "Chargement", + "common.remove": "Retirer", + "common.restore": "Restaurer", + "common.retry": "Réessayer", + "common.searchOrCreate": "Rechercher ou créer…", + "common.settings": "Réglages", + "common.system": "Système", + "common.tools": "Outils", + "settings.appearance": "Apparence", + "settings.light": "Clair", + "settings.dark": "Sombre", + "settings.interfaceLanguage": "Langue de l’interface", + "settings.privacy": "Confidentialité", + "settings.helpImprove": "Aider à améliorer l’app", + "settings.telemetryHelp": "Envoyer des statistiques anonymes d’utilisation des fonctionnalités et des rapports de plantage.", + "settings.support": "Assistance / retours", + "settings.reportIssue": "Signaler un problème", + "settings.homepage": "Page d’accueil", + "settings.github": "GitHub", + "settings.followX": "Suivre sur X", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "Source de transcription", + "model.language": "Langue", + "model.transcriptLanguage": "Langue de transcription", + "model.importTranscript": "Importer une transcription", + "upload.recentProjects": "Projets récents", + "upload.removeRecent": "Retirer des récents", + "upload.dropPrefix": "Déposez un fichier vidéo ou audio ici, ou", + "upload.browse": "parcourir", + "upload.chooseTranscriptFirst": "Choisissez une transcription dans le menu ci-dessus, puis déposez votre média", + "upload.willUseTranscript": "Utilisera {name} · MP4, WebM, MOV, MP3, WAV, …", + "upload.mediaFormats": "MP4, WebM, MOV, MP3, WAV, M4A, …", + "upload.gettingReady": "Préparation", + "upload.gettingReadyHelp": "Configuration du moteur multimédia, cela n’arrive qu’une fois.", + "upload.unsupported": "Ce navigateur ne peut pas lancer l’éditeur", + "upload.unsupportedHelp": + "Le montage nécessite SharedArrayBuffer, qui demande une page isolée entre origines. Essayez une version récente de Chrome, Edge, Safari ou Firefox via HTTPS.", + "upload.transcribeTitle": "Transcrire", + "upload.transcribeText": "Whisper en local, ou importez SRT / VTT.", + "upload.editTitle": "Monter", + "upload.editText": "Sélectionnez des mots et appuyez sur Supprimer pour monter.", + "upload.exportTitle": "Exporter", + "upload.exportText": "Rendez le montage final en MP4 ou M4A.", + "editor.chooseTranscript": "Choisissez d’abord un fichier de transcription dans le menu source.", + "editor.chooseMedia": "Choisissez un fichier vidéo ou audio.", + "editor.undo": "Annuler (⌘Z)", + "editor.redo": "Rétablir (⇧⌘Z)", + "editor.export": "Exporter", + "topbar.startOver": "Recommencer", + "transcript.replace": "Remplacer la transcription depuis SRT, VTT ou JSON", + "transcript.header": "Transcription", + "transcript.wordDeleted": "{count} mot", + "transcript.wordsDeleted": "{count} mots", + "transcript.replaceConfirm": "Remplacer la transcription actuelle par ce fichier ?", + "transcript.invalidFile": "Choisissez un fichier de transcription SRT, VTT ou JSON.", + "transcript.noSpeech": "Aucune parole détectée. Vérifiez que ce fichier contient de l’audio, ou importez une transcription.", + "transcript.cut": "Couper", + "transcript.follow": "Suivre la tête de lecture", + "transcript.hideDeleted": "Masquer les mots supprimés", + "transcript.showDeleted": "Afficher les mots supprimés", + "transcript.correct": "Corriger", + "transcript.scrollWithPlayhead": "Faire défiler avec la tête de lecture", + "transcript.joinSplit": "Clip scindé — cliquez pour joindre ces clips", + "transcript.joinClips": "Joindre les clips", + "transcript.hesitation": "Hésitation détectée (non transcrite) — coupez avec Retirer les mots de remplissage", + "tools.bulk": "Nettoyages groupés de transcription", + "tools.removeFillers": "Retirer les mots de remplissage", + "tools.removeFillersTitle": "Couper les mots de remplissage (\"euh\", \"hum\", \"...\", …) de la vidéo", + "tools.restoreFillers": "Restaurer les mots de remplissage", + "tools.restoreFillersTitle": "Remettre chaque mot de remplissage coupé", + "tools.removeSilences": "Retirer les silences", + "tools.removeSilencesTitle": "Couper les pauses et silences (≥{seconds} s) de la vidéo", + "tools.restoreSilences": "Restaurer les silences", + "tools.restoreSilencesTitle": "Remettre chaque pause coupée", + "timeline.removed": "{trimmed} retiré — durée d’origine {duration}", + "timeline.back5": "Reculer de 5 s", + "timeline.playPause": "Lecture / pause (espace)", + "timeline.forward5": "Avancer de 5 s", + "timeline.split": "Scinder", + "timeline.splitTitle": "Scinder le clip à la tête de lecture (S)", + "timeline.splitDisabled": "Placez la tête de lecture sur une zone conservée pour scinder", + "timeline.delete": "Supprimer", + "timeline.deleteTitle": "Supprimer le clip sélectionné (Suppr)", + "timeline.deleteDisabled": "Sélectionnez un clip sur la timeline pour le supprimer", + "timeline.restore": "Restaurer", + "timeline.restoreTitle": "Restaurer la coupe sélectionnée (Suppr)", + "timeline.restoreDisabled": "Sélectionnez une coupe ou une section de silence sur la timeline pour la restaurer", + "timeline.zoomOut": "Dézoomer", + "timeline.fit": "Adapter à la fenêtre", + "timeline.zoomIn": "Zoomer — faites glisser les bords des mots pour affiner le timing", + "timeline.joinClips": "Joindre ces clips (retirer la scission)", + "timeline.trimStart": "Rogner le début du clip", + "timeline.trimEnd": "Rogner la fin du clip", + "timeline.hesitationAdjust": "… hésitation détectée — faites glisser les bords pour ajuster, ou coupez avec Retirer les mots de remplissage", + "timeline.hesitationCut": "… hésitation détectée — coupez avec Retirer les mots de remplissage", + "timeline.dragTiming": "{word} — faites glisser les bords pour ajuster le timing", + "timeline.scrollZoom": "Faites défiler pour zoomer / dézoomer", + "speaker.moveStart": "Faites glisser pour déplacer le début de ce locuteur", + "speaker.moveLabel": "Déplacer l’étiquette du locuteur", + "speaker.options": "Options du locuteur", + "speaker.change": "Changer de locuteur", + "speaker.rename": "Renommer le locuteur", + "speaker.renameAction": "Renommer", + "speaker.replace": "Remplacer dans le projet par…", + "speaker.removeProject": "Retirer du projet", + "speaker.newName": "Nouveau nom", + "speaker.find": "Rechercher un locuteur…", + "speaker.noMatches": "Aucun locuteur correspondant", + "speaker.renameTo": "Renommer {current} en {next}", + "speaker.button": "Locuteur", + "speaker.search": "Rechercher ou créer…", + "speaker.defaultName": "Locuteur {number}", + "speaker.create": "Créer \"{name}\"", + "export.title": "Exporter", + "export.type": "Type d’export", + "export.video": "Vidéo", + "export.audio": "Audio", + "export.transcript": "Transcription", + "export.subtitles": "Sous-titres", + "export.videoUnavailable": "L’export vidéo n’est pas disponible pour les projets audio uniquement", + "export.noAudio": "Ce fichier n’a pas de piste audio", + "export.noWordsFirst": "Transcrivez ou importez d’abord une transcription", + "export.format": "Format", + "export.resolution": "Résolution", + "export.original": "Original", + "export.plainText": "Texte brut", + "export.statOriginal": "Original", + "export.statCuts": "Coupes", + "export.statEdited": "Monté", + "export.transcriptHelp": "Texte avec étiquettes de locuteur et coupes retirées. Sans horodatage.", + "export.subtitlesHelp": + "SRT et VTT utilisent la timeline montée (coupes appliquées). JSON conserve la liste complète des mots pour une réimportation.", + "export.encodingHelp": "Réencodage avec ffmpeg.wasm — les fichiers longs prennent un moment.", + "export.reexport": "Réexporter avec les dernières modifications", + "export.rendering": "Rendu dans votre navigateur…", + "export.downloadFile": "Télécharger {name}", + "export.downloadFormat": "Télécharger .{format}", + "export.exportFormat": "Exporter {format}", + "import.reading": "Lecture de la transcription…", + "import.importing": "Importation…", + "import.failed": "Échec de l’import", + "import.readingFile": "Lecture du fichier…", + "import.chooseFileShort": "Choisir un fichier…", + "import.chooseFile": "Choisir une transcription SRT, VTT ou JSON", + "import.selected": "{name} sélectionné", + "banner.faster": "Vous voulez des transcriptions et exports plus rapides ?", + "banner.getDesktop": "Obtenir l’app de bureau Rescript", + "banner.downloadFor": "Télécharger pour {platform}", + "banner.dismiss": "Ignorer", + "social.githubRepo": "Dépôt GitHub", + "social.discordServer": "Serveur Discord", + "social.xProfile": "Profil X", + "globalError.title": "Rescript — une erreur est survenue", + "globalError.heading": "Une erreur est survenue", + "globalError.body": + "L’éditeur a rencontré une erreur inattendue. Vos projets enregistrés sont toujours sur cet appareil ; un rechargement devrait les restaurer.", + "progress.loadingMedia": "Chargement du média…", + "progress.loadingMediaEngine": "Chargement du moteur multimédia…", + "progress.extractingAudio": "Extraction de l’audio…", + "progress.loadingSpeechModel": "Chargement du modèle vocal…", + "progress.loadingSpeechCache": "Chargement du modèle vocal depuis le cache…", + "progress.downloadingSpeech": "Téléchargement du modèle vocal…", + "progress.gpuFallback": "GPU interrompu — poursuite sur CPU…", + "progress.detectingSpeech": "Détection de la parole…", + "progress.transcribing": "Transcription…", + "progress.loadingAlignCache": "Chargement du modèle d’alignement depuis le cache…", + "progress.downloadingAlign": "Téléchargement du modèle d’alignement…", + "progress.aligning": "Alignement des mots…", + "progress.speakers": "Identification des locuteurs…", + "error.selectModel": "Sélectionnez un modèle vocal pour transcrire.", + "error.workerCrashed": "Le worker de transcription a planté.", + "error.mediaEngineNetwork": "Impossible de charger le moteur multimédia — la connexion a été interrompue. Vérifiez votre connexion internet et réessayez.", + "error.processFile": "Impossible de traiter ce fichier.", + "error.extractAudio": "Impossible d’extraire l’audio de ce fichier.", + "error.nothingToExport": "Tout a été supprimé — rien à exporter.", + "error.videoExport": "L’export a échoué pendant le rendu de la vidéo.", + "error.audioExport": "L’export a échoué pendant le rendu de l’audio.", + "error.export": "Échec de l’export.", + "error.emptyTranscript": "Ce fichier de transcription est vide.", + "error.noTimedWords": "Aucun mot horodaté trouvé dans cette transcription.", + "error.parseJson": "Impossible d’analyser cette transcription JSON.", + "error.jsonShape": 'JSON doit être un tableau de mots ou { "words": [...] }.', + "error.noWords": "Aucun mot à exporter.", + "error.projectMissing": "Ce projet n’est plus enregistré.", + "error.openProject": "Impossible d’ouvrir ce projet.", + "error.removeProject": "Impossible de retirer ce projet.", + "error.readTranscript": "Impossible de lire cette transcription.", + "error.clearRecent": "Impossible d’effacer les projets récents.", + "error.modelDownload": + "Impossible de terminer le téléchargement du modèle vocal — la connexion a été interrompue. Vérifiez votre connexion internet et réessayez ; les parties déjà téléchargées sont conservées.", + "error.gpuReset": "La transcription a été interrompue lors de la réinitialisation du GPU (souvent après le verrouillage de l’écran). Veuillez réessayer.", + "confirm.clearRecent": "Retirer tous les projets récents ? Leurs modifications enregistrées seront supprimées.", +}; diff --git a/lib/i18n/messages/ja.ts b/lib/i18n/messages/ja.ts new file mode 100644 index 0000000..1718909 --- /dev/null +++ b/lib/i18n/messages/ja.ts @@ -0,0 +1,204 @@ +import type { MessageKey } from "./en"; + +/** Japanese UI catalog. Every key in {@link en} must be present. */ +export const ja: Record = { + "app.title": "Rescript — テキストを編集するように動画を編集", + "common.cancel": "キャンセル", + "common.close": "閉じる", + "common.delete": "削除", + "common.download": "ダウンロード", + "common.import": "インポート", + "common.loading": "読み込み中", + "common.remove": "削除", + "common.restore": "復元", + "common.retry": "もう一度試す", + "common.searchOrCreate": "検索または作成…", + "common.settings": "設定", + "common.system": "システム", + "common.tools": "ツール", + "settings.appearance": "外観", + "settings.light": "ライト", + "settings.dark": "ダーク", + "settings.interfaceLanguage": "表示言語", + "settings.privacy": "プライバシー", + "settings.helpImprove": "アプリの改善に協力", + "settings.telemetryHelp": "匿名の機能利用統計とクラッシュレポートを送信します。", + "settings.support": "サポート / フィードバック", + "settings.reportIssue": "問題を報告", + "settings.homepage": "ホームページ", + "settings.github": "GitHub", + "settings.followX": "X でフォロー", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "文字起こしソース", + "model.language": "言語", + "model.transcriptLanguage": "文字起こし言語", + "model.importTranscript": "文字起こしをインポート", + "upload.recentProjects": "最近のプロジェクト", + "upload.removeRecent": "最近の項目から削除", + "upload.dropPrefix": "動画または音声ファイルをここにドロップ、または", + "upload.browse": "参照", + "upload.chooseTranscriptFirst": "上のメニューで文字起こしを選んでから、メディアをドロップしてください", + "upload.willUseTranscript": "{name} を使用します · MP4、WebM、MOV、MP3、WAV など", + "upload.mediaFormats": "MP4、WebM、MOV、MP3、WAV、M4A など", + "upload.gettingReady": "準備中", + "upload.gettingReadyHelp": "メディアエンジンを設定しています。これは一度だけ行われます。", + "upload.unsupported": "このブラウザではエディターを実行できません", + "upload.unsupportedHelp": + "編集には SharedArrayBuffer が必要で、ページがクロスオリジン分離されている必要があります。HTTPS で最新の Chrome、Edge、Safari、Firefox をお試しください。", + "upload.transcribeTitle": "文字起こし", + "upload.transcribeText": "Whisper をローカルで使うか、SRT / VTT をインポートします。", + "upload.editTitle": "編集", + "upload.editText": "単語を選択して Delete キーを押すだけで編集できます。", + "upload.exportTitle": "書き出し", + "upload.exportText": "完成したカットを MP4 または M4A に書き出します。", + "editor.chooseTranscript": "まずソースメニューから文字起こしファイルを選択してください。", + "editor.chooseMedia": "動画または音声ファイルを選択してください。", + "editor.undo": "取り消し (⌘Z)", + "editor.redo": "やり直し (⇧⌘Z)", + "editor.export": "書き出し", + "topbar.startOver": "最初からやり直す", + "transcript.replace": "SRT、VTT、JSON から文字起こしを置き換え", + "transcript.header": "文字起こし", + "transcript.wordDeleted": "{count} 語", + "transcript.wordsDeleted": "{count} 語", + "transcript.replaceConfirm": "現在の文字起こしをこのファイルで置き換えますか?", + "transcript.invalidFile": "SRT、VTT、JSON の文字起こしファイルを選択してください。", + "transcript.noSpeech": "音声が検出されませんでした。ファイルに音声があるか確認するか、文字起こしをインポートしてください。", + "transcript.cut": "カット", + "transcript.follow": "再生位置に追従", + "transcript.hideDeleted": "削除済みの単語を隠す", + "transcript.showDeleted": "削除済みの単語を表示", + "transcript.correct": "修正", + "transcript.scrollWithPlayhead": "再生位置に合わせてスクロール", + "transcript.joinSplit": "クリップ分割 — クリックしてこれらのクリップを結合", + "transcript.joinClips": "クリップを結合", + "transcript.hesitation": "ためらい音を検出しました(文字起こしなし)— フィラー語を削除でカット", + "tools.bulk": "文字起こしの一括クリーンアップ", + "tools.removeFillers": "フィラー語を削除", + "tools.removeFillersTitle": "動画からフィラー語(\"えー\"、\"あの\"、\"...\" など)をカット", + "tools.restoreFillers": "フィラー語を復元", + "tools.restoreFillersTitle": "カットしたすべてのフィラー語を戻す", + "tools.removeSilences": "無音を削除", + "tools.removeSilencesTitle": "動画から間や無音({seconds}秒以上)をカット", + "tools.restoreSilences": "無音を復元", + "tools.restoreSilencesTitle": "カットしたすべての間を戻す", + "timeline.removed": "{trimmed} 削除済み — 元の長さ {duration}", + "timeline.back5": "5 秒戻る", + "timeline.playPause": "再生 / 一時停止(スペース)", + "timeline.forward5": "5 秒進む", + "timeline.split": "分割", + "timeline.splitTitle": "再生位置でクリップを分割 (S)", + "timeline.splitDisabled": "分割するには、再生位置を残す範囲に移動してください", + "timeline.delete": "削除", + "timeline.deleteTitle": "選択したクリップを削除 (Delete)", + "timeline.deleteDisabled": "削除するクリップをタイムラインで選択してください", + "timeline.restore": "復元", + "timeline.restoreTitle": "選択したカットを復元 (Delete)", + "timeline.restoreDisabled": "復元するカットまたは無音区間をタイムラインで選択してください", + "timeline.zoomOut": "縮小", + "timeline.fit": "ウィンドウに合わせる", + "timeline.zoomIn": "拡大 — 単語の端をドラッグしてタイミングを微調整", + "timeline.joinClips": "これらのクリップを結合(分割を削除)", + "timeline.trimStart": "クリップの開始をトリム", + "timeline.trimEnd": "クリップの終了をトリム", + "timeline.hesitationAdjust": "… ためらい音を検出 — 端をドラッグして調整するか、フィラー語を削除でカット", + "timeline.hesitationCut": "… ためらい音を検出 — フィラー語を削除でカット", + "timeline.dragTiming": "{word} — 端をドラッグしてタイミングを調整", + "timeline.scrollZoom": "スクロールして拡大 / 縮小", + "speaker.moveStart": "この話者が始まる位置をドラッグして移動", + "speaker.moveLabel": "話者ラベルを移動", + "speaker.options": "話者オプション", + "speaker.change": "話者を変更", + "speaker.rename": "話者名を変更", + "speaker.renameAction": "名前を変更", + "speaker.replace": "プロジェクト内で置き換え…", + "speaker.removeProject": "プロジェクトから削除", + "speaker.newName": "新しい名前", + "speaker.find": "話者を検索…", + "speaker.noMatches": "一致する話者がいません", + "speaker.renameTo": "{current} を {next} に名前変更", + "speaker.button": "話者", + "speaker.search": "検索または作成…", + "speaker.defaultName": "話者 {number}", + "speaker.create": "「{name}」を作成", + "export.title": "書き出し", + "export.type": "書き出しタイプ", + "export.video": "動画", + "export.audio": "音声", + "export.transcript": "文字起こし", + "export.subtitles": "字幕", + "export.videoUnavailable": "音声のみのプロジェクトでは動画を書き出せません", + "export.noAudio": "このファイルには音声トラックがありません", + "export.noWordsFirst": "先に文字起こしするか、文字起こしをインポートしてください", + "export.format": "形式", + "export.resolution": "解像度", + "export.original": "元のまま", + "export.plainText": "プレーンテキスト", + "export.statOriginal": "元の長さ", + "export.statCuts": "カット", + "export.statEdited": "編集後", + "export.transcriptHelp": "話者ラベル付きテキスト。カット部分は除外され、タイムスタンプはありません。", + "export.subtitlesHelp": + "SRT と VTT は編集後のタイムライン(カット適用済み)を使用します。JSON は再インポート用に完全な単語リストを保持します。", + "export.encodingHelp": "ffmpeg.wasm で再エンコード中 — 長いファイルは時間がかかります。", + "export.reexport": "最新の編集で再書き出し", + "export.rendering": "ブラウザでレンダリング中…", + "export.downloadFile": "{name} をダウンロード", + "export.downloadFormat": ".{format} をダウンロード", + "export.exportFormat": "{format} を書き出し", + "import.reading": "文字起こしを読み込み中…", + "import.importing": "インポート中…", + "import.failed": "インポートに失敗しました", + "import.readingFile": "ファイルを読み込み中…", + "import.chooseFileShort": "ファイルを選択…", + "import.chooseFile": "SRT、VTT、JSON の文字起こしを選択", + "import.selected": "{name} を選択しました", + "banner.faster": "文字起こしと書き出しをもっと速くしますか?", + "banner.getDesktop": "Rescript デスクトップアプリを入手", + "banner.downloadFor": "{platform} 版をダウンロード", + "banner.dismiss": "閉じる", + "social.githubRepo": "GitHub リポジトリ", + "social.discordServer": "Discord サーバー", + "social.xProfile": "X プロフィール", + "globalError.title": "Rescript — 問題が発生しました", + "globalError.heading": "問題が発生しました", + "globalError.body": + "エディターで予期しないエラーが発生しました。保存済みプロジェクトはこのデバイスに残っています。再読み込みすれば復元されるはずです。", + "progress.loadingMedia": "メディアを読み込み中…", + "progress.loadingMediaEngine": "メディアエンジンを読み込み中…", + "progress.extractingAudio": "音声を抽出中…", + "progress.loadingSpeechModel": "音声モデルを読み込み中…", + "progress.loadingSpeechCache": "キャッシュから音声モデルを読み込み中…", + "progress.downloadingSpeech": "音声モデルをダウンロード中…", + "progress.gpuFallback": "GPU が中断されました — CPU で続行します…", + "progress.detectingSpeech": "音声を検出中…", + "progress.transcribing": "文字起こし中…", + "progress.loadingAlignCache": "キャッシュからアラインメントモデルを読み込み中…", + "progress.downloadingAlign": "アラインメントモデルをダウンロード中…", + "progress.aligning": "単語を整列中…", + "progress.speakers": "話者を識別中…", + "error.selectModel": "文字起こしに使う音声モデルを選択してください。", + "error.workerCrashed": "文字起こしワーカーがクラッシュしました。", + "error.mediaEngineNetwork": "メディアエンジンを読み込めませんでした — 接続が切れました。インターネット接続を確認して、もう一度お試しください。", + "error.processFile": "このファイルを処理できませんでした。", + "error.extractAudio": "このファイルから音声を抽出できませんでした。", + "error.nothingToExport": "すべて削除されています — 書き出すものがありません。", + "error.videoExport": "動画のレンダリング中に書き出しに失敗しました。", + "error.audioExport": "音声のレンダリング中に書き出しに失敗しました。", + "error.export": "書き出しに失敗しました。", + "error.emptyTranscript": "その文字起こしファイルは空です。", + "error.noTimedWords": "その文字起こしにタイミング付きの単語がありません。", + "error.parseJson": "その JSON 文字起こしを解析できませんでした。", + "error.jsonShape": 'JSON は単語配列、または { "words": [...] } である必要があります。', + "error.noWords": "書き出す単語がありません。", + "error.projectMissing": "そのプロジェクトはもう保存されていません。", + "error.openProject": "そのプロジェクトを開けませんでした。", + "error.removeProject": "そのプロジェクトを削除できませんでした。", + "error.readTranscript": "その文字起こしを読み込めませんでした。", + "error.clearRecent": "最近のプロジェクトを消去できませんでした。", + "error.modelDownload": + "音声モデルのダウンロードを完了できませんでした — 接続が切れました。インターネット接続を確認してもう一度お試しください。完了した部分は保持されます。", + "error.gpuReset": "GPU がリセットされたため文字起こしが中断されました(画面ロック後によく起きます)。もう一度お試しください。", + "confirm.clearRecent": "最近のプロジェクトをすべて削除しますか?保存済みの編集も削除されます。", +}; diff --git a/lib/i18n/messages/ko.ts b/lib/i18n/messages/ko.ts new file mode 100644 index 0000000..40fda7b --- /dev/null +++ b/lib/i18n/messages/ko.ts @@ -0,0 +1,204 @@ +import type { MessageKey } from "./en"; + +/** Korean UI catalog. Every key in {@link en} must be present. */ +export const ko: Record = { + "app.title": "Rescript — 텍스트를 편집하듯 동영상 편집", + "common.cancel": "취소", + "common.close": "닫기", + "common.delete": "삭제", + "common.download": "다운로드", + "common.import": "가져오기", + "common.loading": "불러오는 중", + "common.remove": "제거", + "common.restore": "복원", + "common.retry": "다시 시도", + "common.searchOrCreate": "검색 또는 만들기…", + "common.settings": "설정", + "common.system": "시스템", + "common.tools": "도구", + "settings.appearance": "모양", + "settings.light": "라이트", + "settings.dark": "다크", + "settings.interfaceLanguage": "인터페이스 언어", + "settings.privacy": "개인정보", + "settings.helpImprove": "앱 개선에 참여", + "settings.telemetryHelp": "익명의 기능 사용 통계와 충돌 보고서를 보냅니다.", + "settings.support": "지원 / 피드백", + "settings.reportIssue": "문제 신고", + "settings.homepage": "홈페이지", + "settings.github": "GitHub", + "settings.followX": "X에서 팔로우", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "자막 원본", + "model.language": "언어", + "model.transcriptLanguage": "자막 언어", + "model.importTranscript": "자막 가져오기", + "upload.recentProjects": "최근 프로젝트", + "upload.removeRecent": "최근 목록에서 제거", + "upload.dropPrefix": "동영상 또는 오디오 파일을 여기에 놓거나", + "upload.browse": "찾아보기", + "upload.chooseTranscriptFirst": "먼저 위 메뉴에서 자막을 선택한 다음 미디어를 놓으세요", + "upload.willUseTranscript": "{name} 사용 예정 · MP4, WebM, MOV, MP3, WAV 등", + "upload.mediaFormats": "MP4, WebM, MOV, MP3, WAV, M4A 등", + "upload.gettingReady": "준비 중", + "upload.gettingReadyHelp": "미디어 엔진을 설정하고 있습니다. 한 번만 진행됩니다.", + "upload.unsupported": "이 브라우저에서는 편집기를 실행할 수 없습니다", + "upload.unsupportedHelp": + "편집에는 SharedArrayBuffer가 필요하며, 페이지가 교차 출처 격리 상태여야 합니다. HTTPS에서 최신 Chrome, Edge, Safari 또는 Firefox를 사용해 보세요.", + "upload.transcribeTitle": "자막 만들기", + "upload.transcribeText": "Whisper를 로컬에서 실행하거나 SRT / VTT를 가져옵니다.", + "upload.editTitle": "편집", + "upload.editText": "단어를 선택하고 Delete 키를 눌러 편집하세요.", + "upload.exportTitle": "내보내기", + "upload.exportText": "최종 컷을 MP4 또는 M4A로 렌더링합니다.", + "editor.chooseTranscript": "먼저 소스 메뉴에서 자막 파일을 선택하세요.", + "editor.chooseMedia": "동영상 또는 오디오 파일을 선택하세요.", + "editor.undo": "실행 취소 (⌘Z)", + "editor.redo": "다시 실행 (⇧⌘Z)", + "editor.export": "내보내기", + "topbar.startOver": "처음부터 다시", + "transcript.replace": "SRT, VTT 또는 JSON에서 자막 바꾸기", + "transcript.header": "자막", + "transcript.wordDeleted": "{count}개 단어", + "transcript.wordsDeleted": "{count}개 단어", + "transcript.replaceConfirm": "현재 자막을 이 파일로 바꿀까요?", + "transcript.invalidFile": "SRT, VTT 또는 JSON 자막 파일을 선택하세요.", + "transcript.noSpeech": "음성이 감지되지 않았습니다. 파일에 오디오가 있는지 확인하거나 자막을 가져오세요.", + "transcript.cut": "컷", + "transcript.follow": "재생 위치 따라가기", + "transcript.hideDeleted": "삭제된 단어 숨기기", + "transcript.showDeleted": "삭제된 단어 보기", + "transcript.correct": "수정", + "transcript.scrollWithPlayhead": "재생 위치에 맞춰 스크롤", + "transcript.joinSplit": "클립 분할 — 클릭하면 이 클립들을 합칩니다", + "transcript.joinClips": "클립 합치기", + "transcript.hesitation": "머뭇거림 감지됨(자막 없음) — 필러 단어 제거로 컷", + "tools.bulk": "자막 일괄 정리", + "tools.removeFillers": "필러 단어 제거", + "tools.removeFillersTitle": "동영상에서 필러 단어(\"음\", \"어\", \"...\" 등)를 컷", + "tools.restoreFillers": "필러 단어 복원", + "tools.restoreFillersTitle": "컷한 모든 필러 단어를 되돌리기", + "tools.removeSilences": "무음 제거", + "tools.removeSilencesTitle": "동영상에서 멈춤과 무음({seconds}초 이상)을 컷", + "tools.restoreSilences": "무음 복원", + "tools.restoreSilencesTitle": "컷한 모든 멈춤을 되돌리기", + "timeline.removed": "{trimmed} 제거됨 — 원본 길이 {duration}", + "timeline.back5": "5초 뒤로", + "timeline.playPause": "재생 / 일시정지(스페이스)", + "timeline.forward5": "5초 앞으로", + "timeline.split": "분할", + "timeline.splitTitle": "재생 위치에서 클립 분할(S)", + "timeline.splitDisabled": "분할하려면 재생 위치를 유지할 구간으로 옮기세요", + "timeline.delete": "삭제", + "timeline.deleteTitle": "선택한 클립 삭제(Delete)", + "timeline.deleteDisabled": "삭제할 클립을 타임라인에서 선택하세요", + "timeline.restore": "복원", + "timeline.restoreTitle": "선택한 컷 복원(Delete)", + "timeline.restoreDisabled": "복원할 컷 또는 무음 구간을 타임라인에서 선택하세요", + "timeline.zoomOut": "축소", + "timeline.fit": "창에 맞추기", + "timeline.zoomIn": "확대 — 단어 가장자리를 드래그해 타이밍 조정", + "timeline.joinClips": "이 클립들 합치기(분할 제거)", + "timeline.trimStart": "클립 시작 자르기", + "timeline.trimEnd": "클립 끝 자르기", + "timeline.hesitationAdjust": "… 머뭇거림 감지됨 — 가장자리를 드래그해 조정하거나 필러 단어 제거로 컷", + "timeline.hesitationCut": "… 머뭇거림 감지됨 — 필러 단어 제거로 컷", + "timeline.dragTiming": "{word} — 가장자리를 드래그해 타이밍 조정", + "timeline.scrollZoom": "스크롤하여 확대 / 축소", + "speaker.moveStart": "이 화자가 시작되는 위치를 드래그해 이동", + "speaker.moveLabel": "화자 라벨 이동", + "speaker.options": "화자 옵션", + "speaker.change": "화자 변경", + "speaker.rename": "화자 이름 변경", + "speaker.renameAction": "이름 변경", + "speaker.replace": "프로젝트에서 다음으로 바꾸기…", + "speaker.removeProject": "프로젝트에서 제거", + "speaker.newName": "새 이름", + "speaker.find": "화자 찾기…", + "speaker.noMatches": "일치하는 화자가 없습니다", + "speaker.renameTo": "{current}을(를) {next}(으)로 이름 변경", + "speaker.button": "화자", + "speaker.search": "검색 또는 만들기…", + "speaker.defaultName": "화자 {number}", + "speaker.create": "\"{name}\" 만들기", + "export.title": "내보내기", + "export.type": "내보내기 유형", + "export.video": "동영상", + "export.audio": "오디오", + "export.transcript": "자막", + "export.subtitles": "자막 파일", + "export.videoUnavailable": "오디오 전용 프로젝트에서는 동영상을 내보낼 수 없습니다", + "export.noAudio": "이 파일에는 오디오 트랙이 없습니다", + "export.noWordsFirst": "먼저 자막을 만들거나 가져오세요", + "export.format": "형식", + "export.resolution": "해상도", + "export.original": "원본", + "export.plainText": "일반 텍스트", + "export.statOriginal": "원본", + "export.statCuts": "컷", + "export.statEdited": "편집본", + "export.transcriptHelp": "화자 라벨이 포함된 텍스트이며 컷한 내용은 제거됩니다. 타임스탬프는 없습니다.", + "export.subtitlesHelp": + "SRT와 VTT는 편집된 타임라인(컷 적용)을 사용합니다. JSON은 다시 가져오기 위해 전체 단어 목록을 유지합니다.", + "export.encodingHelp": "ffmpeg.wasm으로 다시 인코딩 중 — 긴 파일은 시간이 걸립니다.", + "export.reexport": "최신 편집으로 다시 내보내기", + "export.rendering": "브라우저에서 렌더링 중…", + "export.downloadFile": "{name} 다운로드", + "export.downloadFormat": ".{format} 다운로드", + "export.exportFormat": "{format} 내보내기", + "import.reading": "자막 읽는 중…", + "import.importing": "가져오는 중…", + "import.failed": "가져오기 실패", + "import.readingFile": "파일 읽는 중…", + "import.chooseFileShort": "파일 선택…", + "import.chooseFile": "SRT, VTT 또는 JSON 자막 선택", + "import.selected": "{name} 선택됨", + "banner.faster": "자막 만들기와 내보내기를 더 빠르게 하고 싶나요?", + "banner.getDesktop": "Rescript 데스크톱 앱 받기", + "banner.downloadFor": "{platform}용 다운로드", + "banner.dismiss": "닫기", + "social.githubRepo": "GitHub 저장소", + "social.discordServer": "Discord 서버", + "social.xProfile": "X 프로필", + "globalError.title": "Rescript — 문제가 발생했습니다", + "globalError.heading": "문제가 발생했습니다", + "globalError.body": + "편집기에서 예상치 못한 오류가 발생했습니다. 저장된 프로젝트는 이 기기에 남아 있으니, 다시 불러오면 복구될 것입니다.", + "progress.loadingMedia": "미디어 불러오는 중…", + "progress.loadingMediaEngine": "미디어 엔진 불러오는 중…", + "progress.extractingAudio": "오디오 추출 중…", + "progress.loadingSpeechModel": "음성 모델 불러오는 중…", + "progress.loadingSpeechCache": "캐시에서 음성 모델 불러오는 중…", + "progress.downloadingSpeech": "음성 모델 다운로드 중…", + "progress.gpuFallback": "GPU가 중단됨 — CPU로 계속합니다…", + "progress.detectingSpeech": "음성 감지 중…", + "progress.transcribing": "자막 만드는 중…", + "progress.loadingAlignCache": "캐시에서 정렬 모델 불러오는 중…", + "progress.downloadingAlign": "정렬 모델 다운로드 중…", + "progress.aligning": "단어 정렬 중…", + "progress.speakers": "화자 식별 중…", + "error.selectModel": "자막을 만들 음성 모델을 선택하세요.", + "error.workerCrashed": "자막 생성 워커가 충돌했습니다.", + "error.mediaEngineNetwork": "미디어 엔진을 불러오지 못했습니다 — 연결이 끊겼습니다. 인터넷을 확인한 뒤 다시 시도하세요.", + "error.processFile": "이 파일을 처리하지 못했습니다.", + "error.extractAudio": "이 파일에서 오디오를 추출할 수 없습니다.", + "error.nothingToExport": "모든 내용이 삭제되었습니다 — 내보낼 항목이 없습니다.", + "error.videoExport": "동영상을 렌더링하는 중 내보내기에 실패했습니다.", + "error.audioExport": "오디오를 렌더링하는 중 내보내기에 실패했습니다.", + "error.export": "내보내기에 실패했습니다.", + "error.emptyTranscript": "해당 자막 파일이 비어 있습니다.", + "error.noTimedWords": "해당 자막에서 시간 정보가 있는 단어를 찾지 못했습니다.", + "error.parseJson": "해당 JSON 자막을 파싱할 수 없습니다.", + "error.jsonShape": 'JSON은 단어 배열이거나 { "words": [...] }여야 합니다.', + "error.noWords": "내보낼 단어가 없습니다.", + "error.projectMissing": "해당 프로젝트는 더 이상 저장되어 있지 않습니다.", + "error.openProject": "해당 프로젝트를 열 수 없습니다.", + "error.removeProject": "해당 프로젝트를 제거할 수 없습니다.", + "error.readTranscript": "해당 자막을 읽을 수 없습니다.", + "error.clearRecent": "최근 프로젝트를 지울 수 없습니다.", + "error.modelDownload": + "음성 모델 다운로드를 완료하지 못했습니다 — 연결이 끊겼습니다. 인터넷을 확인한 뒤 다시 시도하세요. 완료된 부분은 유지됩니다.", + "error.gpuReset": "GPU가 재설정되어 자막 생성이 중단되었습니다(화면 잠금 후 자주 발생). 다시 시도하세요.", + "confirm.clearRecent": "최근 프로젝트를 모두 제거할까요? 저장된 편집 내용도 삭제됩니다.", +}; diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts new file mode 100644 index 0000000..5299ec0 --- /dev/null +++ b/lib/i18n/messages/zh-TW.ts @@ -0,0 +1,204 @@ +import type { MessageKey } from "./en"; + +/** Traditional Chinese UI catalog. Every key in {@link en} must be present. */ +export const zhTW: Record = { + "app.title": "Rescript — 像編輯文字一樣編輯影片", + "common.cancel": "取消", + "common.close": "關閉", + "common.delete": "刪除", + "common.download": "下載", + "common.import": "匯入", + "common.loading": "載入中", + "common.remove": "移除", + "common.restore": "復原", + "common.retry": "再試一次", + "common.searchOrCreate": "搜尋或新增…", + "common.settings": "設定", + "common.system": "跟隨系統", + "common.tools": "工具", + "settings.appearance": "外觀", + "settings.light": "淺色", + "settings.dark": "深色", + "settings.interfaceLanguage": "介面語言", + "settings.privacy": "隱私", + "settings.helpImprove": "協助改善應用程式", + "settings.telemetryHelp": "傳送匿名功能使用統計與當機報告。", + "settings.support": "支援 / 意見回饋", + "settings.reportIssue": "回報問題", + "settings.homepage": "首頁", + "settings.github": "GitHub", + "settings.followX": "在 X 上追蹤", + "language.english": "English", + "language.simplifiedChinese": "简体中文", + "model.transcriptSource": "逐字稿來源", + "model.language": "語言", + "model.transcriptLanguage": "逐字稿語言", + "model.importTranscript": "匯入逐字稿", + "upload.recentProjects": "最近專案", + "upload.removeRecent": "從最近專案移除", + "upload.dropPrefix": "將影片或音訊檔拖到這裡,或", + "upload.browse": "瀏覽", + "upload.chooseTranscriptFirst": "先在上方選單選擇逐字稿,再拖入媒體", + "upload.willUseTranscript": "將使用 {name} · MP4、WebM、MOV、MP3、WAV 等", + "upload.mediaFormats": "MP4、WebM、MOV、MP3、WAV、M4A 等", + "upload.gettingReady": "正在準備", + "upload.gettingReadyHelp": "正在設定媒體引擎,只會發生一次。", + "upload.unsupported": "此瀏覽器無法執行編輯器", + "upload.unsupportedHelp": + "編輯需要 SharedArrayBuffer,因此頁面必須是跨來源隔離。請透過 HTTPS 使用較新的 Chrome、Edge、Safari 或 Firefox。", + "upload.transcribeTitle": "轉錄", + "upload.transcribeText": "在本機使用 Whisper,或匯入 SRT / VTT。", + "upload.editTitle": "編輯", + "upload.editText": "選取文字並按刪除即可剪輯。", + "upload.exportTitle": "匯出", + "upload.exportText": "將完成的剪輯算出為 MP4 或 M4A。", + "editor.chooseTranscript": "請先從來源選單選擇逐字稿檔案。", + "editor.chooseMedia": "請選擇影片或音訊檔。", + "editor.undo": "復原 (⌘Z)", + "editor.redo": "重做 (⇧⌘Z)", + "editor.export": "匯出", + "topbar.startOver": "重新開始", + "transcript.replace": "用 SRT、VTT 或 JSON 取代逐字稿", + "transcript.header": "逐字稿", + "transcript.wordDeleted": "{count} 個字", + "transcript.wordsDeleted": "{count} 個字", + "transcript.replaceConfirm": "要用此檔案取代目前的逐字稿嗎?", + "transcript.invalidFile": "請選擇 SRT、VTT 或 JSON 逐字稿檔。", + "transcript.noSpeech": "未偵測到語音。請確認檔案有音訊,或匯入逐字稿。", + "transcript.cut": "剪除", + "transcript.follow": "跟隨播放頭", + "transcript.hideDeleted": "隱藏已刪除文字", + "transcript.showDeleted": "顯示已刪除文字", + "transcript.correct": "修正", + "transcript.scrollWithPlayhead": "隨播放頭捲動", + "transcript.joinSplit": "片段分割 — 點一下即可合併這些片段", + "transcript.joinClips": "合併片段", + "transcript.hesitation": "偵測到猶豫聲(未轉錄)— 可用「移除填充詞」剪除", + "tools.bulk": "批次清理逐字稿", + "tools.removeFillers": "移除填充詞", + "tools.removeFillersTitle": "從影片中剪除填充詞(\"嗯\"、\"呃\"、\"...\" 等)", + "tools.restoreFillers": "還原填充詞", + "tools.restoreFillersTitle": "把所有已剪除的填充詞加回來", + "tools.removeSilences": "移除靜音", + "tools.removeSilencesTitle": "從影片中剪除停頓和靜音(≥{seconds} 秒)", + "tools.restoreSilences": "還原靜音", + "tools.restoreSilencesTitle": "把所有已剪除的停頓加回來", + "timeline.removed": "已剪除 {trimmed} — 原始長度 {duration}", + "timeline.back5": "倒退 5 秒", + "timeline.playPause": "播放 / 暫停(空白鍵)", + "timeline.forward5": "前進 5 秒", + "timeline.split": "分割", + "timeline.splitTitle": "在播放頭位置分割片段 (S)", + "timeline.splitDisabled": "將播放頭移到保留區域上才能分割", + "timeline.delete": "刪除", + "timeline.deleteTitle": "刪除選取的片段 (Delete)", + "timeline.deleteDisabled": "請先在時間軸上選取要刪除的片段", + "timeline.restore": "復原", + "timeline.restoreTitle": "復原選取的剪除區域 (Delete)", + "timeline.restoreDisabled": "請在時間軸上選取要復原的剪除或靜音區段", + "timeline.zoomOut": "縮小", + "timeline.fit": "符合視窗", + "timeline.zoomIn": "放大 — 拖曳文字邊緣可微調時間", + "timeline.joinClips": "合併這些片段(移除分割)", + "timeline.trimStart": "修剪片段開頭", + "timeline.trimEnd": "修剪片段結尾", + "timeline.hesitationAdjust": "… 偵測到猶豫聲 — 拖曳邊緣調整,或用「移除填充詞」剪除", + "timeline.hesitationCut": "… 偵測到猶豫聲 — 可用「移除填充詞」剪除", + "timeline.dragTiming": "{word} — 拖曳邊緣可調整時間", + "timeline.scrollZoom": "捲動以放大 / 縮小", + "speaker.moveStart": "拖曳以移動此說話人開始的位置", + "speaker.moveLabel": "移動說話人標籤", + "speaker.options": "說話人選項", + "speaker.change": "變更說話人", + "speaker.rename": "重新命名說話人", + "speaker.renameAction": "重新命名", + "speaker.replace": "在專案中取代為…", + "speaker.removeProject": "從專案移除", + "speaker.newName": "新名稱", + "speaker.find": "尋找說話人…", + "speaker.noMatches": "沒有符合的說話人", + "speaker.renameTo": "將 {current} 重新命名為 {next}", + "speaker.button": "說話人", + "speaker.search": "搜尋或新增…", + "speaker.defaultName": "說話人 {number}", + "speaker.create": "建立「{name}」", + "export.title": "匯出", + "export.type": "匯出類型", + "export.video": "影片", + "export.audio": "音訊", + "export.transcript": "逐字稿", + "export.subtitles": "字幕", + "export.videoUnavailable": "純音訊專案無法匯出影片", + "export.noAudio": "此檔案沒有音軌", + "export.noWordsFirst": "請先轉錄或匯入逐字稿", + "export.format": "格式", + "export.resolution": "解析度", + "export.original": "原始", + "export.plainText": "純文字", + "export.statOriginal": "原始", + "export.statCuts": "剪除", + "export.statEdited": "成片", + "export.transcriptHelp": "帶有說話人標籤的文字,已移除剪除內容。不含時間戳。", + "export.subtitlesHelp": + "SRT 和 VTT 使用已套用剪除的編輯後時間軸。JSON 保留完整詞表以便重新匯入。", + "export.encodingHelp": "使用 ffmpeg.wasm 重新編碼 — 較長的檔案需要一些時間。", + "export.reexport": "以最新編輯重新匯出", + "export.rendering": "正在瀏覽器中算出…", + "export.downloadFile": "下載 {name}", + "export.downloadFormat": "下載 .{format}", + "export.exportFormat": "匯出 {format}", + "import.reading": "正在讀取逐字稿…", + "import.importing": "正在匯入…", + "import.failed": "匯入失敗", + "import.readingFile": "正在讀取檔案…", + "import.chooseFileShort": "選擇檔案…", + "import.chooseFile": "選擇 SRT、VTT 或 JSON 逐字稿", + "import.selected": "已選擇 {name}", + "banner.faster": "想要更快轉錄和匯出?", + "banner.getDesktop": "取得 Rescript 桌面應用程式", + "banner.downloadFor": "下載 {platform} 版", + "banner.dismiss": "關閉", + "social.githubRepo": "GitHub 儲存庫", + "social.discordServer": "Discord 伺服器", + "social.xProfile": "X 個人檔案", + "globalError.title": "Rescript — 發生錯誤", + "globalError.heading": "發生錯誤", + "globalError.body": + "編輯器遇到未預期的錯誤。你已儲存的專案仍在此裝置上,重新載入後應該會恢復。", + "progress.loadingMedia": "正在載入媒體…", + "progress.loadingMediaEngine": "正在載入媒體引擎…", + "progress.extractingAudio": "正在擷取音訊…", + "progress.loadingSpeechModel": "正在載入語音模型…", + "progress.loadingSpeechCache": "正在從快取載入語音模型…", + "progress.downloadingSpeech": "正在下載語音模型…", + "progress.gpuFallback": "GPU 中斷 — 正在改用 CPU 繼續…", + "progress.detectingSpeech": "正在偵測語音…", + "progress.transcribing": "正在轉錄…", + "progress.loadingAlignCache": "正在從快取載入對齊模型…", + "progress.downloadingAlign": "正在下載對齊模型…", + "progress.aligning": "正在對齊文字…", + "progress.speakers": "正在辨識說話人…", + "error.selectModel": "請選擇要用來轉錄的語音模型。", + "error.workerCrashed": "轉錄 worker 已當機。", + "error.mediaEngineNetwork": "無法載入媒體引擎 — 連線中斷。請檢查網路後再試一次。", + "error.processFile": "無法處理此檔案。", + "error.extractAudio": "無法從此檔案擷取音訊。", + "error.nothingToExport": "所有內容都已刪除 — 沒有可匯出的內容。", + "error.videoExport": "算出影片時匯出失敗。", + "error.audioExport": "算出音訊時匯出失敗。", + "error.export": "匯出失敗。", + "error.emptyTranscript": "該逐字稿檔案是空的。", + "error.noTimedWords": "該逐字稿中找不到帶時間的文字。", + "error.parseJson": "無法解析該 JSON 逐字稿。", + "error.jsonShape": 'JSON 必須是文字陣列,或 { "words": [...] }。', + "error.noWords": "沒有可匯出的文字。", + "error.projectMissing": "該專案已不在已儲存專案中。", + "error.openProject": "無法開啟該專案。", + "error.removeProject": "無法移除該專案。", + "error.readTranscript": "無法讀取該逐字稿。", + "error.clearRecent": "無法清除最近專案。", + "error.modelDownload": + "無法完成語音模型下載 — 連線中斷。請檢查網路後再試一次;已下載完成的部分會保留。", + "error.gpuReset": "GPU 重置時轉錄中斷(常見於鎖定螢幕後)。請再試一次。", + "confirm.clearRecent": "要移除所有最近專案嗎?已儲存的編輯也會被刪除。", +}; From 5686aff7e8de574f4c5cb862126a19831beb00ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:39:47 +0000 Subject: [PATCH 4/4] feat(i18n): support multi-locale detection and six more languages Make UI locale registration data-driven, detect Traditional Chinese / Japanese / Korean / Spanish / French / German from the system language list, and ship matching renderer + Electron catalogs plus NSIS languages. Co-authored-by: Wassim Gharbi --- app/layout.tsx | 3 +- components/I18nProvider.tsx | 3 +- components/SettingsMenu.tsx | 20 ++-- electron/locale/catalogs.ts | 22 +++++ electron/locale/index.ts | 23 +++-- electron/main.ts | 6 +- electron/preload.ts | 2 +- electron/tsconfig.json | 4 +- lib/i18n/catalogs.ts | 22 +++++ lib/i18n/index.ts | 59 ++++++------ lib/i18n/locales.ts | 179 ++++++++++++++++++++++++++++++++++++ lib/i18n/messages/de.ts | 2 - lib/i18n/messages/en.ts | 2 - lib/i18n/messages/es.ts | 2 - lib/i18n/messages/fr.ts | 2 - lib/i18n/messages/ja.ts | 2 - lib/i18n/messages/ko.ts | 2 - lib/i18n/messages/zh-CN.ts | 2 - lib/i18n/messages/zh-TW.ts | 2 - package.json | 8 +- tests/i18n-test.ts | 79 ++++++++-------- types/rescript-desktop.d.ts | 4 +- 22 files changed, 340 insertions(+), 110 deletions(-) create mode 100644 electron/locale/catalogs.ts create mode 100644 lib/i18n/catalogs.ts create mode 100644 lib/i18n/locales.ts diff --git a/app/layout.tsx b/app/layout.tsx index b3b52ab..1c7f06f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import Script from "next/script"; import { GoogleAnalytics } from "@next/third-parties/google"; import { Analytics } from "@vercel/analytics/next"; +import { buildLocaleBootScript } from "@/lib/i18n"; import "./globals.css"; const geistSans = Geist({ @@ -64,7 +65,7 @@ export const metadata: Metadata = { /** Apply stored appearance before paint to avoid a light→dark flash. */ const appearanceBootScript = `(function(){try{if(localStorage.getItem("rescript.appearance")==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`; -const localeBootScript = `(function(){try{var p=localStorage.getItem("rescript.ui-locale")||"system";var l=p;if(p==="system"){var a=navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language];l="en";for(var i=0;i { - document.documentElement.lang = locale; + document.documentElement.lang = UI_LOCALE_META[locale].htmlLang; document.title = translate(locale, "app.title"); window.rescriptDesktop?.setUiLocale(locale); }, [locale]); diff --git a/components/SettingsMenu.tsx b/components/SettingsMenu.tsx index 02c1288..1c0efbf 100644 --- a/components/SettingsMenu.tsx +++ b/components/SettingsMenu.tsx @@ -16,7 +16,11 @@ import { useTelemetryPref } from "@/hooks/useTelemetryPref"; import Popover, { PopoverContent, PopoverTrigger } from "./Popover"; import type { Appearance } from "@/lib/theme"; import { useI18n } from "./I18nProvider"; -import type { UiLocalePreference } from "@/lib/i18n"; +import { + UI_LOCALES, + UI_LOCALE_META, + isUiLocalePreference, +} from "@/lib/i18n"; const MENU_LINKS = [ { labelKey: "settings.support", href: DISCORD_INVITE_URL, Icon: DiscordIcon }, @@ -101,14 +105,18 @@ export default function SettingsMenu() { {t("settings.interfaceLanguage")} diff --git a/electron/locale/catalogs.ts b/electron/locale/catalogs.ts new file mode 100644 index 0000000..2f86bbf --- /dev/null +++ b/electron/locale/catalogs.ts @@ -0,0 +1,22 @@ +import type { UiLocale } from "../../lib/i18n/locales"; +import { de } from "./de"; +import { en, type DesktopMessageKey } from "./en"; +import { es } from "./es"; +import { fr } from "./fr"; +import { ja } from "./ja"; +import { ko } from "./ko"; +import { zhCN } from "./zh-CN"; +import { zhTW } from "./zh-TW"; + +export type DesktopMessageCatalog = Record; + +export const desktopCatalogs: Record = { + en, + "zh-CN": zhCN, + "zh-TW": zhTW, + ja, + ko, + es, + fr, + de, +}; diff --git a/electron/locale/index.ts b/electron/locale/index.ts index b0721cb..455c63e 100644 --- a/electron/locale/index.ts +++ b/electron/locale/index.ts @@ -1,25 +1,34 @@ -import { en, type DesktopMessageKey } from "./en"; -import { zhCN } from "./zh-CN"; +import { + isUiLocale, + resolveUiLocale, + type UiLocale, +} from "../../lib/i18n/locales"; +import { desktopCatalogs } from "./catalogs"; +import type { DesktopMessageKey } from "./en"; -export type DesktopLocale = "en" | "zh-CN"; +export type DesktopLocale = UiLocale; +export type { DesktopMessageKey }; let currentLocale: DesktopLocale = "en"; +/** Map Electron's `app.getLocale()` onto a supported desktop UI locale. */ export function resolveDesktopLocale(value: string): DesktopLocale { - const locale = value.toLowerCase(); - // Any zh* tag maps to Simplified Chinese until Traditional UI lands. - return locale === "zh" || locale.startsWith("zh-") ? "zh-CN" : "en"; + return resolveUiLocale("system", [value]); } export function setDesktopLocale(locale: DesktopLocale): void { currentLocale = locale; } +export function isDesktopLocale(value: unknown): value is DesktopLocale { + return isUiLocale(value); +} + export function desktopText( key: DesktopMessageKey, params: Record = {} ): string { - const template = (currentLocale === "zh-CN" ? zhCN : en)[key]; + const template = desktopCatalogs[currentLocale][key] ?? desktopCatalogs.en[key]; return template.replace(/\{(\w+)\}/g, (token, name: string) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token ); diff --git a/electron/main.ts b/electron/main.ts index 0ebc049..64ea0f5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -20,9 +20,9 @@ import { type RecentProject, } from "./menu"; import { + isDesktopLocale, resolveDesktopLocale, setDesktopLocale, - type DesktopLocale, } from "./locale"; const isDev = !app.isPackaged; @@ -340,8 +340,8 @@ if (!gotLock) { setMainTelemetryEnabled(value === true); }); ipcMain.on("ui:set-locale", (_event, value: unknown) => { - if (value !== "en" && value !== "zh-CN") return; - setDesktopLocale(value as DesktopLocale); + if (!isDesktopLocale(value)) return; + setDesktopLocale(value); buildAppMenu(); }); // The saved projects live in the renderer's IndexedDB; it pushes a snapshot diff --git a/electron/preload.ts b/electron/preload.ts index f4c0900..289e34b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -25,7 +25,7 @@ contextBridge.exposeInMainWorld("rescriptDesktop", { ipcRenderer.send("telemetry:set-enabled", enabled); }, /** Keep native menus and dialogs in sync with the renderer preference. */ - setUiLocale: (locale: "en" | "zh-CN") => { + setUiLocale: (locale: string) => { ipcRenderer.send("ui:set-locale", locale); }, /** diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 854a4fb..9247f81 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "node", "lib": ["ES2022"], "outDir": "../electron-dist", - "rootDir": ".", + "rootDir": "..", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -13,6 +13,6 @@ "declaration": false, "sourceMap": true }, - "include": ["./**/*.ts"], + "include": ["./**/*.ts", "../lib/i18n/locales.ts"], "exclude": ["node_modules"] } diff --git a/lib/i18n/catalogs.ts b/lib/i18n/catalogs.ts new file mode 100644 index 0000000..165b0d2 --- /dev/null +++ b/lib/i18n/catalogs.ts @@ -0,0 +1,22 @@ +import { en, type MessageKey } from "./messages/en"; +import { de } from "./messages/de"; +import { es } from "./messages/es"; +import { fr } from "./messages/fr"; +import { ja } from "./messages/ja"; +import { ko } from "./messages/ko"; +import { zhCN } from "./messages/zh-CN"; +import { zhTW } from "./messages/zh-TW"; +import type { UiLocale } from "./locales"; + +export type MessageCatalog = Record; + +export const catalogs: Record = { + en, + "zh-CN": zhCN, + "zh-TW": zhTW, + ja, + ko, + es, + fr, + de, +}; diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index e44c7ba..e57ac78 100644 --- a/lib/i18n/index.ts +++ b/lib/i18n/index.ts @@ -1,5 +1,12 @@ -import { en, type MessageKey } from "./messages/en"; -import { zhCN } from "./messages/zh-CN"; +import { catalogs } from "./catalogs"; +import { + DEFAULT_UI_LOCALE_PREFERENCE, + UI_LOCALE_STORAGE_KEY, + isUiLocalePreference, + type UiLocale, + type UiLocalePreference, +} from "./locales"; +import type { MessageKey } from "./messages/en"; import { runtimeMessageKeys } from "./runtimeMessages"; export type { MessageKey } from "./messages/en"; @@ -8,42 +15,28 @@ export { runtimeMessageKeys, type RuntimeMessageKey, } from "./runtimeMessages"; - -export type UiLocale = "en" | "zh-CN"; -export type UiLocalePreference = "system" | UiLocale; - -export const DEFAULT_UI_LOCALE_PREFERENCE: UiLocalePreference = "system"; -export const UI_LOCALE_STORAGE_KEY = "rescript.ui-locale"; +export { + DEFAULT_UI_LOCALE, + DEFAULT_UI_LOCALE_PREFERENCE, + UI_LOCALES, + UI_LOCALE_META, + UI_LOCALE_STORAGE_KEY, + buildLocaleBootScript, + isUiLocale, + isUiLocalePreference, + matchUiLocale, + nsisInstallerLanguages, + resolveUiLocale, + type UiLocale, + type UiLocaleMeta, + type UiLocalePreference, +} from "./locales"; export type Translate = ( key: MessageKey, params?: Record ) => string; -export function isUiLocalePreference(value: unknown): value is UiLocalePreference { - return value === "system" || value === "en" || value === "zh-CN"; -} - -/** - * Resolve the effective UI locale. - * - * For `system`, the first supported language in the list wins. Any `zh*` tag - * (including zh-HK / zh-TW) currently maps to Simplified Chinese — Traditional - * Chinese is not a separate UI locale yet. - */ -export function resolveUiLocale( - preference: UiLocalePreference, - systemLanguages: readonly string[] -): UiLocale { - if (preference !== "system") return preference; - for (const raw of systemLanguages) { - const language = raw.toLowerCase(); - if (language === "zh" || language.startsWith("zh-")) return "zh-CN"; - if (language === "en" || language.startsWith("en-")) return "en"; - } - return "en"; -} - export function systemLanguages(): string[] { if (typeof navigator === "undefined") return []; return navigator.languages?.length @@ -87,7 +80,7 @@ export function translate( key: MessageKey, params: Record = {} ): string { - const template = (locale === "zh-CN" ? zhCN : en)[key] ?? en[key]; + const template = catalogs[locale][key] ?? catalogs.en[key]; return interpolate(template, params); } diff --git a/lib/i18n/locales.ts b/lib/i18n/locales.ts new file mode 100644 index 0000000..640e1b5 --- /dev/null +++ b/lib/i18n/locales.ts @@ -0,0 +1,179 @@ +/** + * Supported UI locales and BCP-47 matching rules. + * + * Adding a locale: append an entry here, add `messages/.ts` + + * `electron/locale/.ts`, and register the catalog in the message maps. + * Detection, Settings, the boot script, and Electron IPC all read from this list. + */ +export const UI_LOCALES = [ + "en", + "zh-CN", + "zh-TW", + "ja", + "ko", + "es", + "fr", + "de", +] as const; + +export type UiLocale = (typeof UI_LOCALES)[number]; +export type UiLocalePreference = "system" | UiLocale; + +export const DEFAULT_UI_LOCALE: UiLocale = "en"; +export const DEFAULT_UI_LOCALE_PREFERENCE: UiLocalePreference = "system"; +export const UI_LOCALE_STORAGE_KEY = "rescript.ui-locale"; + +export type UiLocaleMeta = { + /** BCP-47 tag used for `document.documentElement.lang` and Intl. */ + htmlLang: string; + /** Native endonym shown in the language picker (not translated). */ + nativeLabel: string; + /** electron-builder NSIS language code, when an installer translation exists. */ + nsis?: string; + /** Return true when a lowered BCP-47 tag should resolve to this locale. */ + match: (tag: string) => boolean; +}; + +const prefix = + (base: string) => + (tag: string): boolean => + tag === base || tag.startsWith(`${base}-`); + +export const UI_LOCALE_META: Record = { + en: { + htmlLang: "en", + nativeLabel: "English", + nsis: "en_US", + match: prefix("en"), + }, + "zh-CN": { + htmlLang: "zh-CN", + nativeLabel: "简体中文", + nsis: "zh_CN", + // Bare `zh` and Hans variants → Simplified. Traditional tags are handled by zh-TW. + match: (tag) => { + if (isTraditionalChinese(tag)) return false; + return tag === "zh" || tag.startsWith("zh-"); + }, + }, + "zh-TW": { + htmlLang: "zh-TW", + nativeLabel: "繁體中文", + nsis: "zh_TW", + match: isTraditionalChinese, + }, + ja: { + htmlLang: "ja", + nativeLabel: "日本語", + nsis: "ja_JP", + match: prefix("ja"), + }, + ko: { + htmlLang: "ko", + nativeLabel: "한국어", + nsis: "ko_KR", + match: prefix("ko"), + }, + es: { + htmlLang: "es", + nativeLabel: "Español", + nsis: "es_ES", + match: prefix("es"), + }, + fr: { + htmlLang: "fr", + nativeLabel: "Français", + nsis: "fr_FR", + match: prefix("fr"), + }, + de: { + htmlLang: "de", + nativeLabel: "Deutsch", + nsis: "de_DE", + match: prefix("de"), + }, +}; + +function isTraditionalChinese(tag: string): boolean { + if (tag === "zh-hant" || tag.startsWith("zh-hant-")) return true; + if (tag === "zh-tw" || tag.startsWith("zh-tw-")) return true; + if (tag === "zh-hk" || tag.startsWith("zh-hk-")) return true; + if (tag === "zh-mo" || tag.startsWith("zh-mo-")) return true; + return false; +} + +/** Match order: check Traditional Chinese before the broad Simplified `zh*` rule. */ +const MATCH_ORDER: readonly UiLocale[] = [ + "zh-TW", + "zh-CN", + "ja", + "ko", + "es", + "fr", + "de", + "en", +]; + +export function isUiLocale(value: unknown): value is UiLocale { + return typeof value === "string" && (UI_LOCALES as readonly string[]).includes(value); +} + +export function isUiLocalePreference(value: unknown): value is UiLocalePreference { + return value === "system" || isUiLocale(value); +} + +/** + * Map a BCP-47 tag onto a supported UI locale, or `null` if unsupported. + * Traditional Chinese tags win over the generic `zh*` → Simplified rule. + */ +export function matchUiLocale(tag: string): UiLocale | null { + const normalized = tag.trim().toLowerCase().replaceAll("_", "-"); + if (!normalized) return null; + for (const locale of MATCH_ORDER) { + if (UI_LOCALE_META[locale].match(normalized)) return locale; + } + return null; +} + +/** + * Resolve the effective UI locale. + * For `system`, the first supported language in the browser/OS list wins. + */ +export function resolveUiLocale( + preference: UiLocalePreference, + systemLanguages: readonly string[] +): UiLocale { + if (preference !== "system") return preference; + for (const raw of systemLanguages) { + const matched = matchUiLocale(raw); + if (matched) return matched; + } + return DEFAULT_UI_LOCALE; +} + +/** Inline boot script so `document.documentElement.lang` is correct before paint. */ +export function buildLocaleBootScript(): string { + const rules = MATCH_ORDER.map((locale) => { + const meta = UI_LOCALE_META[locale]; + // Encode each matcher as an explicit check list for the boot IIFE. + if (locale === "zh-TW") { + return `if(v==="zh-hant"||v.indexOf("zh-hant-")===0||v==="zh-tw"||v.indexOf("zh-tw-")===0||v==="zh-hk"||v.indexOf("zh-hk-")===0||v==="zh-mo"||v.indexOf("zh-mo-")===0){l=${JSON.stringify(locale)};break}`; + } + if (locale === "zh-CN") { + return `if(v==="zh"||v.indexOf("zh-")===0){l=${JSON.stringify(locale)};break}`; + } + const base = meta.htmlLang.split("-")[0]; + return `if(v===${JSON.stringify(base)}||v.indexOf(${JSON.stringify(`${base}-`)})===0){l=${JSON.stringify(locale)};break}`; + }).join(""); + + return `(function(){try{var p=localStorage.getItem(${JSON.stringify(UI_LOCALE_STORAGE_KEY)})||"system";var l=p;if(p==="system"){var a=navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language];l=${JSON.stringify(DEFAULT_UI_LOCALE)};for(var i=0;i = { "settings.homepage": "Homepage", "settings.github": "GitHub", "settings.followX": "Auf X folgen", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "Transkriptquelle", "model.language": "Sprache", "model.transcriptLanguage": "Transkriptsprache", diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index b6aab16..7268395 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -27,8 +27,6 @@ export const en = { "settings.homepage": "Homepage", "settings.github": "GitHub", "settings.followX": "Follow on X", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "Transcript source", "model.language": "Language", "model.transcriptLanguage": "Transcript language", diff --git a/lib/i18n/messages/es.ts b/lib/i18n/messages/es.ts index 7e8116f..693fbbc 100644 --- a/lib/i18n/messages/es.ts +++ b/lib/i18n/messages/es.ts @@ -28,8 +28,6 @@ export const es: Record = { "settings.homepage": "Página de inicio", "settings.github": "GitHub", "settings.followX": "Seguir en X", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "Origen de la transcripción", "model.language": "Idioma", "model.transcriptLanguage": "Idioma de transcripción", diff --git a/lib/i18n/messages/fr.ts b/lib/i18n/messages/fr.ts index efee275..0cb4074 100644 --- a/lib/i18n/messages/fr.ts +++ b/lib/i18n/messages/fr.ts @@ -28,8 +28,6 @@ export const fr: Record = { "settings.homepage": "Page d’accueil", "settings.github": "GitHub", "settings.followX": "Suivre sur X", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "Source de transcription", "model.language": "Langue", "model.transcriptLanguage": "Langue de transcription", diff --git a/lib/i18n/messages/ja.ts b/lib/i18n/messages/ja.ts index 1718909..fe5bef3 100644 --- a/lib/i18n/messages/ja.ts +++ b/lib/i18n/messages/ja.ts @@ -28,8 +28,6 @@ export const ja: Record = { "settings.homepage": "ホームページ", "settings.github": "GitHub", "settings.followX": "X でフォロー", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "文字起こしソース", "model.language": "言語", "model.transcriptLanguage": "文字起こし言語", diff --git a/lib/i18n/messages/ko.ts b/lib/i18n/messages/ko.ts index 40fda7b..adbe0ad 100644 --- a/lib/i18n/messages/ko.ts +++ b/lib/i18n/messages/ko.ts @@ -28,8 +28,6 @@ export const ko: Record = { "settings.homepage": "홈페이지", "settings.github": "GitHub", "settings.followX": "X에서 팔로우", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "자막 원본", "model.language": "언어", "model.transcriptLanguage": "자막 언어", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 78fb160..c0c2e9a 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -28,8 +28,6 @@ export const zhCN: Record = { "settings.homepage": "主页", "settings.github": "GitHub", "settings.followX": "在 X 上关注", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "转录来源", "model.language": "语言", "model.transcriptLanguage": "转录语言", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 5299ec0..dccaf88 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -28,8 +28,6 @@ export const zhTW: Record = { "settings.homepage": "首頁", "settings.github": "GitHub", "settings.followX": "在 X 上追蹤", - "language.english": "English", - "language.simplifiedChinese": "简体中文", "model.transcriptSource": "逐字稿來源", "model.language": "語言", "model.transcriptLanguage": "逐字稿語言", diff --git a/package.json b/package.json index 01252f0..6f1c84b 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,13 @@ "allowToChangeInstallationDirectory": true, "installerLanguages": [ "en_US", - "zh_CN" + "zh_CN", + "zh_TW", + "ja_JP", + "ko_KR", + "es_ES", + "fr_FR", + "de_DE" ], "multiLanguageInstaller": true, "artifactName": "${productName}-Setup.${ext}" diff --git a/tests/i18n-test.ts b/tests/i18n-test.ts index 0e38080..847afe6 100644 --- a/tests/i18n-test.ts +++ b/tests/i18n-test.ts @@ -1,31 +1,42 @@ import { + UI_LOCALES, + buildLocaleBootScript, formatRelativeTime, localizeRuntimeMessage, + matchUiLocale, + nsisInstallerLanguages, resolveUiLocale, runtimeEnglishMessages, runtimeMessageKeys, translate, } from "../lib/i18n"; +import { catalogs } from "../lib/i18n/catalogs"; import { en, type MessageKey } from "../lib/i18n/messages/en"; -import { zhCN } from "../lib/i18n/messages/zh-CN"; function assert(value: unknown, message: string): asserts value { if (!value) throw new Error(message); } assert(resolveUiLocale("system", ["zh-CN"]) === "zh-CN", "zh-CN detection"); -assert(resolveUiLocale("system", ["zh-HK"]) === "zh-CN", "zh-HK fallback"); -assert(resolveUiLocale("system", ["fr-FR", "en-US"]) === "en", "ordered fallback"); -assert(resolveUiLocale("system", ["fr-FR", "zh-Hans"]) === "zh-CN", "secondary zh"); +assert(resolveUiLocale("system", ["zh-HK"]) === "zh-TW", "zh-HK → Traditional"); +assert(resolveUiLocale("system", ["zh-TW"]) === "zh-TW", "zh-TW detection"); +assert(resolveUiLocale("system", ["zh-Hans-CN"]) === "zh-CN", "zh-Hans"); +assert(resolveUiLocale("system", ["ja-JP"]) === "ja", "japanese detection"); +assert(resolveUiLocale("system", ["ko"]) === "ko", "korean detection"); +assert(resolveUiLocale("system", ["es-MX"]) === "es", "spanish detection"); +assert(resolveUiLocale("system", ["fr-CA"]) === "fr", "french detection"); +assert(resolveUiLocale("system", ["de-AT"]) === "de", "german detection"); +assert(resolveUiLocale("system", ["fr-FR", "en-US"]) === "fr", "ordered fallback"); +assert(resolveUiLocale("system", ["pt-BR", "en-US"]) === "en", "unsupported then en"); assert(resolveUiLocale("system", []) === "en", "empty fallback"); -assert(resolveUiLocale("zh-CN", ["en-US"]) === "zh-CN", "manual zh override"); -assert(resolveUiLocale("en", ["zh-CN"]) === "en", "manual en override"); +assert(resolveUiLocale("ja", ["en-US"]) === "ja", "manual override"); +assert(matchUiLocale("zh_TW") === "zh-TW", "underscore normalized"); assert(translate("zh-CN", "common.settings") === "设置", "Chinese settings"); -assert(translate("en", "common.settings") === "Settings", "English settings"); +assert(translate("ja", "common.settings") === "設定", "Japanese settings"); +assert(translate("es", "common.settings") === "Ajustes", "Spanish settings"); assert( - translate("zh-CN", "export.downloadFile", { name: "demo.mp4" }) === - "下载 demo.mp4", + translate("de", "export.downloadFile", { name: "demo.mp4" }).includes("demo.mp4"), "named interpolation" ); assert( @@ -37,52 +48,44 @@ assert( "plural words deleted" ); -const zh = (key: MessageKey, params?: Record) => - translate("zh-CN", key, params); +const ja = (key: MessageKey, params?: Record) => + translate("ja", key, params); assert( - localizeRuntimeMessage("Transcribing…", zh) === "正在转录…", + localizeRuntimeMessage("Transcribing…", ja).length > 0, "runtime progress localization" ); assert( - localizeRuntimeMessage("No words to export.", zh) === "没有可导出的文字。", + localizeRuntimeMessage("No words to export.", ja).length > 0, "runtime error localization" ); -assert( - localizeRuntimeMessage( - 'JSON must be a word array or { "words": [...] }.', - zh - ) === "JSON 必须是文字数组,或包含 words 字段的对象。", - "json shape localization" -); -assert( - localizeRuntimeMessage( - "Couldn't finish downloading the speech model — the connection dropped. Check your internet and try again; the parts that finished downloading are kept.", - zh - ).includes("语音模型"), - "model download localization" -); -assert(localizeRuntimeMessage("Unknown diagnostic", zh) === "Unknown diagnostic", "fallback"); +assert(localizeRuntimeMessage("Unknown diagnostic", ja) === "Unknown diagnostic", "fallback"); -// Runtime map is derived from the English catalog — every lookup key must match. for (const english of runtimeEnglishMessages) { assert(runtimeMessageKeys[english], `runtime map covers ${english}`); const key = runtimeMessageKeys[english]; assert(en[key] === english, `catalog matches runtime english for ${key}`); } -// Catalogs stay complete across locales. const enKeys = Object.keys(en) as MessageKey[]; -for (const key of enKeys) { - assert(typeof zhCN[key] === "string" && zhCN[key].length > 0, `zh-CN has ${key}`); +for (const locale of UI_LOCALES) { + for (const key of enKeys) { + assert( + typeof catalogs[locale][key] === "string" && catalogs[locale][key].length > 0, + `${locale} has ${key}` + ); + } } +const boot = buildLocaleBootScript(); +assert(boot.includes("zh-TW"), "boot script knows Traditional Chinese"); +assert(boot.includes("ja"), "boot script knows Japanese"); +assert(boot.includes("navigator.languages"), "boot script reads system languages"); + +const nsis = nsisInstallerLanguages(); +assert(nsis.includes("en_US") && nsis.includes("ja_JP") && nsis.includes("zh_TW"), "nsis codes"); + const now = Date.UTC(2026, 7, 9, 12, 0, 0); -assert(formatRelativeTime("zh-CN", now - 5 * 60_000, now).includes("5"), "zh relative"); +assert(formatRelativeTime("ja", now - 5 * 60_000, now).includes("5"), "ja relative"); assert(formatRelativeTime("en", now - 5 * 60_000, now).includes("5"), "en relative"); -assert( - formatRelativeTime("en", now - 10_000, now).toLowerCase().includes("now") || - formatRelativeTime("en", now - 10_000, now).includes("second"), - "en just now" -); console.log("ALL I18N TESTS PASSED"); diff --git a/types/rescript-desktop.d.ts b/types/rescript-desktop.d.ts index 4ffcdd8..128dc7b 100644 --- a/types/rescript-desktop.d.ts +++ b/types/rescript-desktop.d.ts @@ -1,3 +1,5 @@ +import type { UiLocale } from "@/lib/i18n/locales"; + /** Resting sizes the Electron shell switches between. */ export type WindowMode = "compact" | "expanded"; @@ -23,7 +25,7 @@ export interface RescriptDesktop { /** Mirror the telemetry opt-out to the main process, which gates its own reporting. */ setTelemetryEnabled: (enabled: boolean) => void; /** Keep native menus and dialogs aligned with the resolved UI locale. */ - setUiLocale: (locale: "en" | "zh-CN") => void; + setUiLocale: (locale: UiLocale) => void; /** Publish the saved-project list (newest first) for File › Recent Projects. */ setRecentProjects: (projects: Array<{ id: string; name: string }>) => void; /** Subscribe to File-menu actions; returns an unsubscribe function. */