返回 html-video
i18n.js
1 /**
2 * Tiny i18n for the studio. No build step, no framework.
3 *
4 * Usage:
5 * import { t, setLocale, getLocale, AVAILABLE_LOCALES } from './i18n.js';
6 * t('toolbar.export_mp4')
7 * setLocale('zh')
8 *
9 * Locale resolution order:
10 * 1. localStorage hv.studio.locale
11 * 2. navigator.language prefix ("zh-CN" → "zh")
12 * 3. DEFAULT_LOCALE = "en"
13 *
14 * Strings missing in the active locale fall back to en, then the key.
15 */
16
17 export const DEFAULT_LOCALE = 'en';
18 export const AVAILABLE_LOCALES = ['en', 'zh'];
19
20 const DICT = {
21 en: {
22 'app.empty_pick_create': 'Pick or create a project',
23 'app.empty_subtitle':
24 'Each project = one HTML video. Choose a template to see the visual baseline, chat with your agent to drive the content, edit per-frame text in the middle column, see the result on the right.',
25 'app.no_project': 'no project',
26
27 'sidebar.projects': 'Projects',
28 'sidebar.new': '+ New',
29 'sidebar.collapse': 'Collapse sidebar',
30 'sidebar.empty_list': 'no projects yet',
31 'sidebar.menu.rename': '✎ Rename',
32 'sidebar.menu.delete': '🗑 Delete',
33 'sidebar.rename_prompt': 'New project name',
34 'sidebar.delete_confirm': 'Delete "{name}"? This cannot be undone.',
35
36 'toolbar.template': 'Template',
37 'toolbar.template_pick': 'Optional · Pick template',
38 'toolbar.agent': 'Agent',
39 'toolbar.model': 'Model',
40 'toolbar.agent_none': '— none —',
41 'toolbar.agent_ready': '● ready',
42 'toolbar.agent_install': '○ install',
43 'toolbar.export_mp4': 'Export MP4',
44
45 'composer.placeholder.no_project': 'Pick a project first…',
46 'composer.placeholder.detecting_agents': 'Describe the video while we check for agents…',
47 'composer.placeholder.no_agent': 'Install Claude Code (claude CLI) to enable chat…',
48 'composer.placeholder.focus':
49 'Edit only this frame (click ✕ on the chip above to release)…',
50 'composer.placeholder.no_template':
51 'Describe a video, or paste an article link / GitHub repo to build one from it.',
52 'composer.placeholder.with_template': 'Describe the video — content, names, data… or paste an article link / GitHub repo.',
53 'composer.hint': 'Cmd / Ctrl + Enter · drag / paste files · drop a design.md / frame.md to lock brand + motion',
54 'composer.send': 'Send',
55 'composer.attach': 'Attach file',
56 'composer.focus_chip': 'Editing only frame {order} {fid}',
57 'composer.focus_clear': 'Clear focus',
58
59 'chat.empty.title': 'Send a message to start',
60 'chat.empty.body':
61 'Tell the agent what you want — a single brand card, a multi-frame teaser, a data poster — and it will scaffold the HTML.',
62 'chat.summary.form_submitted': '📋 Form submitted',
63 'chat.summary.confirm_generate': '✓ Generate',
64 'chat.summary.confirm_edit': '✏️ Edit',
65 'chat.thinking': 'agent thinking',
66 'chat.still_generating': '⏳ This project is still generating in the background — its result will appear here when done (reload preview to refresh).',
67 'chat.placeholder.gen_html': '📄 *generating HTML…*',
68 'chat.placeholder.plan_graph': '🧭 *planning storyboard…*',
69 'chat.empty_reply':
70 '⚠️ The agent returned an empty reply. Try rephrasing your request — e.g. tell it the brand / topic / 1-2 concrete details, or which kind of frame you want first.',
71
72 'preview.placeholder.pick_project': 'Pick a project first.',
73 'preview.placeholder.pick_template':
74 'Send a chat to generate the first HTML.<br>Or pick a template up top for a quick start.',
75 'preview.edit_text_on': '✓ Done editing',
76 'preview.edit_text_off': '✎ Edit text',
77 'preview.edit_text_title': 'Click any text in the preview to edit',
78 'preview.edit_text_done_title': 'Finish editing',
79 'preview.reload': '↻ Reload preview',
80 'preview.no_hv_text':
81 'This frame has no editable text (HTML missing data-hv-text).',
82
83 'frames.label': 'Frames',
84 'frames.view_graph': 'View graph',
85 'frames.enhance': '⚡ Remotion',
86 'frames.enhance_hint': 'Render this data frame natively with Remotion (numbers roll, bars grow)',
87 'frames.enhanced_revert': '⚡ Remotion ✓ (revert)',
88 'frames.enhancing': '⚡ {pct}%…',
89 'enhance.done': '⚡ Frame rendered with Remotion',
90 'enhance.failed': '⚡ Remotion failed: {message}',
91
92 'text_pane.title': 'Frame text',
93 'text_pane.no_project': 'No project.',
94 'text_pane.empty_with_frames':
95 'No editable text on this frame. Switch to another frame, or click ✎ Edit text on the canvas.',
96 'text_pane.empty_no_frames':
97 'No editable text yet. Send a chat to generate the first version of the HTML, then per-frame text fields appear here.',
98 'text_pane.collapse': 'Collapse panel',
99 'text_pane.save_state.idle': '—',
100 'text_pane.save_state.typing': 'typing…',
101 'text_pane.save_state.saving': 'saving…',
102 'text_pane.save_state.saved': 'saved',
103 'text_pane.save_state.error': 'error',
104
105 'export.starting': '⏵ Starting MP4 export…',
106 'export.button_running': '⏵ {pct}% · {stage}',
107 'export.done_seconds': '✓ MP4 exported · {seconds}',
108 'export.done_no_seconds': '✓ MP4 exported',
109 'export.failed': '⚠️ Export failed: {message}',
110 'export.stream_interrupted': 'Export stream interrupted: {message}',
111 'export.failed_short': 'Export failed: {message}',
112 'export.title': '🎬 MP4 ready',
113 'export.reveal': 'Reveal in Finder',
114 'export.copy_path': 'Copy path',
115 'export.copied': 'Path copied',
116 'export.copy_failed': 'Copy failed: {message}',
117 'export.reveal_failed': 'Open failed: {message}',
118
119 'soundtrack.title': '🎵 Add background music & narration',
120 'soundtrack.summary_sub': 'AI music + voiceover, mixed into your export',
121 'soundtrack.optional': 'optional',
122 'soundtrack.hint': 'Background music + narration, mixed into the MP4 on export.',
123 'soundtrack.music_label': 'Background music',
124 'soundtrack.music_placeholder': 'Pick a style above, or describe your own — genre, mood, tempo',
125 'soundtrack.preset_energetic': 'Energetic',
126 'soundtrack.preset_calm': 'Calm',
127 'soundtrack.preset_tech': 'Tech',
128 'soundtrack.preset_narrative': 'Narrative',
129 'soundtrack.preset_minimal': 'Minimal',
130 'soundtrack.preset_epic': 'Epic',
131 'soundtrack.narration_label': 'Narration / voiceover',
132 'soundtrack.step_write': 'Write the script (text)',
133 'soundtrack.step_voice': 'Synthesize the voice (audio)',
134 'soundtrack.editing_frame': '· editing frame {n}/{total}',
135 'soundtrack.draft_frame': '✨ Draft this frame',
136 'soundtrack.draft_all': '✨ Draft all frames',
137 'soundtrack.gen_music': '🎵 Generate music',
138 'soundtrack.gen_narration': '🎙 Synthesize voiceover',
139 'soundtrack.empty_music': 'Pick or describe a music style first.',
140 'soundtrack.empty_narration': 'Add narration text first (✨ to draft it).',
141 'soundtrack.frame_word': 'Frame',
142 'soundtrack.total_word': 'Total',
143 'soundtrack.drafting': 'Drafting…',
144 'soundtrack.draft_need_frames': 'Generate the video first',
145 'soundtrack.draft_failed': '⚠️ Draft failed: {message}',
146 'soundtrack.narration_placeholder': 'One line per frame — click ✨ to draft from the video (leave empty for none)',
147 'soundtrack.voice_label': 'Voice',
148 'soundtrack.voice_male_warm': 'Male · Warm',
149 'soundtrack.voice_male_pro': 'Male · Professional',
150 'soundtrack.voice_male_deep': 'Male · Deep',
151 'soundtrack.voice_female_anchor': 'Female · Anchor',
152 'soundtrack.voice_female_mature': 'Female · Mature',
153 'soundtrack.voice_female_sweet': 'Female · Sweet',
154 'soundtrack.fit_durations': '⇄ Fit timing to narration',
155 'soundtrack.fit_hint': 'Re-pace each frame by how much narration it has',
156 'soundtrack.fitting': 'Fitting…',
157 'soundtrack.fitted': '✓ Frame timing fit to narration · {sec}s total',
158 'soundtrack.fit_failed': 'Could not fit timing',
159 'soundtrack.music_volume': 'Music volume',
160 'soundtrack.narration_volume': 'Narration volume',
161 'soundtrack.generate': 'Generate soundtrack',
162 'soundtrack.generating': 'Generating…',
163 'soundtrack.clear': 'Clear',
164 'soundtrack.starting': '⏵ Generating soundtrack…',
165 'soundtrack.progress_music': '⏵ Generating background music…',
166 'soundtrack.progress_narration': '⏵ Generating narration…',
167 'soundtrack.done': '✓ Soundtrack ready — it will be mixed in on export',
168 'soundtrack.failed': '⚠️ Soundtrack failed: {message}',
169 'soundtrack.music_ready': 'Music',
170 'soundtrack.narration_ready': 'Narration',
171 'soundtrack.empty': 'Enter a music prompt and/or narration text first.',
172
173 'graph.title': 'Content graph',
174 'graph.download': '⬇ Download JSON',
175 'graph.close': '✕',
176 'graph.empty': '(no graph for this project)',
177 'graph.error': 'error loading graph: {message}',
178
179 'gallery.title': 'Pick a template',
180 'gallery.close': '✕',
181
182 'modal.new.title': 'New project',
183 'modal.new.name_label': 'Name',
184 'modal.new.name_placeholder': 'e.g. nexu-io launch teaser',
185 'modal.new.intent_label': 'Intent (optional)',
186 'modal.new.intent_placeholder': 'A one-line description of what this video is about',
187 'modal.new.cancel': 'Cancel',
188 'modal.new.create': 'Create',
189 'modal.new.name_required': 'Name is required',
190 'modal.new.created': 'Created "{name}"',
191 'modal.new.failed': 'Failed to create project',
192
193 'language.label': 'Language',
194
195 'settings.title': 'Settings',
196 'settings.tab.agent': 'Agent',
197 'settings.tab.audio': 'Audio',
198 'settings.tab.language': 'Language',
199 'settings.tab.about': 'About',
200
201 'settings.audio.title': 'Audio · MiniMax',
202 'settings.audio.subtitle': 'API key for soundtrack generation (background music + narration).',
203 'settings.audio.loading': 'Checking…',
204 'settings.audio.api_key': 'API key',
205 'settings.audio.api_key_placeholder': 'Paste your MiniMax API key',
206 'settings.audio.region': 'API region',
207 'settings.audio.region_intl': '🌐 International · api.minimax.io',
208 'settings.audio.region_cn': '🇨🇳 China · api.minimaxi.com',
209 'settings.audio.base_url': 'Base URL',
210 'settings.audio.save': 'Save',
211 'settings.audio.clear': 'Clear',
212 'settings.audio.configured': '✓ Configured · {key} · from {source}',
213 'settings.audio.not_configured': 'No MiniMax key configured yet.',
214 'settings.audio.source_config': 'Settings',
215 'settings.audio.source_env': 'environment',
216 'settings.audio.saving': 'Saving…',
217 'settings.audio.saved': '✓ Saved',
218 'settings.audio.save_failed': 'Save failed: {message}',
219 'settings.audio.need_key': 'Enter an API key first.',
220 'settings.audio.hint': 'Stored locally in .html-video/media-config.json. Pick the region that matches your key — an api.minimax.io (International) key will NOT work against api.minimaxi.com (China), and vice-versa. The old api.minimaxi.chat host is retired.',
221
222 'settings.agent.title': 'Agent',
223 'settings.agent.subtitle': 'Pick the runtime that turns your chat into HTML.',
224 'settings.agent.mode.local': 'Local CLI',
225 'settings.agent.mode.byok': 'BYOK (API)',
226 'settings.agent.detected': 'Detected agents ({count})',
227 'settings.agent.test': 'Test',
228 'settings.agent.testing': 'Testing…',
229 'settings.agent.test_ok': 'OK · {ms}ms · {bytes}B',
230 'settings.agent.test_fail': 'Failed: {message}',
231 'settings.agent.empty_reply': 'Failed: agent returned an empty reply',
232 'settings.agent.use': 'Use',
233 'settings.agent.in_use': 'In use',
234 'settings.agent.unavailable': 'Not installed',
235 'agent.sign_in': 'Sign in',
236 'agent.signing_in': 'Signing in…',
237 'agent.signed_in': '✓ Signed in to AMR',
238 'agent.sign_in_failed': 'Sign-in failed',
239 'agent.recommended': 'Recommended · one login, many models',
240 'settings.agent.byok.intro': 'Use your own Anthropic / OpenRouter API key. Reads from environment:',
241 'settings.agent.byok.env_key': 'ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN',
242 'settings.agent.byok.env_base': 'ANTHROPIC_BASE_URL (optional, defaults to api.anthropic.com)',
243 'settings.agent.rescan': '↻ Rescan',
244 'settings.agent.rescanned': 'Rescanned',
245
246 'settings.language.title': 'Language',
247 'settings.language.subtitle': 'Studio interface language. Re-renders instantly.',
248 'settings.language.en': 'English',
249 'settings.language.zh': '中文',
250 'settings.language.en_sub': 'EN',
251 'settings.language.zh_sub': 'ZH-CN',
252
253 'settings.about.title': 'About',
254 'settings.about.subtitle': 'html-video — open-source HTML→Video meta-layer for coding agents.',
255 'settings.about.version': 'Version',
256 'settings.about.repo': 'Repo',
257 'settings.about.discord': 'Discord',
258 'settings.about.license': 'License',
259 'settings.about.related': 'Related',
260
261 'toolbar.settings': 'Settings',
262
263 'tpl_preview.cancel': 'Cancel',
264 'tpl_preview.use': 'Use this template',
265 'tpl_preview.replace_confirm': 'Replace current template with "{name}"? Existing preview content stays put — the agent can rebuild on next chat.',
266 'tpl_preview.applied': 'Template: {name}',
267 'tpl_preview.fps_dur': '{fps}fps · {duration}s · {aspect}',
268 'tpl_preview.source_skill': 'Adapted from',
269 'tpl_preview.source_origin': 'Design lineage',
270 'tpl_preview.source_license': 'License',
271 },
272
273 zh: {
274 'app.empty_pick_create': '挑一个项目或新建',
275 'app.empty_subtitle':
276 '每个项目 = 一个 HTML 视频。挑一个模板看视觉基线、跟 agent 聊驱动内容、在中间栏改逐帧文字、右边看效果。',
277 'app.no_project': '未选项目',
278
279 'sidebar.projects': '项目',
280 'sidebar.new': '+ 新建',
281 'sidebar.collapse': '收起侧栏',
282 'sidebar.empty_list': '还没有项目',
283 'sidebar.menu.rename': '✎ 重命名',
284 'sidebar.menu.delete': '🗑 删除',
285 'sidebar.rename_prompt': '新项目名',
286 'sidebar.delete_confirm': '删除 "{name}"?此操作不可撤销。',
287
288 'toolbar.template': '模板',
289 'toolbar.template_pick': '可选 · 挑模板',
290 'toolbar.agent': 'Agent',
291 'toolbar.model': '模型',
292 'toolbar.agent_none': '— 无 —',
293 'toolbar.agent_ready': '● 就绪',
294 'toolbar.agent_install': '○ 待装',
295 'toolbar.export_mp4': '导出 MP4',
296
297 'composer.placeholder.no_project': '先选一个项目…',
298 'composer.placeholder.detecting_agents': '描述视频(正在探测 agent)…',
299 'composer.placeholder.no_agent': '装 claude CLI 后即可聊天…',
300 'composer.placeholder.focus': '只修改这一帧的内容(点掉上方芯片可恢复整片)…',
301 'composer.placeholder.no_template': '描述视频,或粘一个文章链接 / GitHub repo 据此生成。',
302 'composer.placeholder.with_template': '描述视频 — 内容、名字、数据…或粘文章链接 / GitHub repo。',
303 'composer.hint': 'Cmd / Ctrl + Enter · 拖拽 / 粘贴文件 · 拖入 design.md / frame.md 锁定品牌+动效',
304 'composer.send': '发送',
305 'composer.attach': '附加文件',
306 'composer.focus_chip': '仅修改第 {order} 帧 {fid}',
307 'composer.focus_clear': '清除',
308
309 'chat.empty.title': '发条消息开始',
310 'chat.empty.body':
311 '告诉 agent 想做什么 — 单帧标题卡、多帧预告片、数据大字报 — 它会搭出 HTML。',
312 'chat.summary.form_submitted': '📋 提交了表单',
313 'chat.summary.confirm_generate': '✓ 确认生成',
314 'chat.summary.confirm_edit': '✏️ 改一下',
315 'chat.thinking': 'agent 思考中',
316 'chat.still_generating': '⏳ 这个项目仍在后台生成中 —— 完成后结果会出现在这里(点重载预览可刷新)。',
317 'chat.placeholder.gen_html': '📄 *正在生成 HTML…*',
318 'chat.placeholder.plan_graph': '🧭 *规划故事板…*',
319 'chat.empty_reply':
320 '⚠️ Agent 返回为空。试着重新表述 — 比如告诉它品牌 / 主题 / 1-2 个具体点,或者你想要什么类型的帧。',
321
322 'preview.placeholder.pick_project': '先选一个项目。',
323 'preview.placeholder.pick_template':
324 '发一条消息让 agent 生成第一版 HTML。<br>或上方挑一个模板快开。',
325 'preview.edit_text_on': '✓ 完成编辑',
326 'preview.edit_text_off': '✎ 编辑文字',
327 'preview.edit_text_title': '点画面里的文字直接修改',
328 'preview.edit_text_done_title': '完成编辑',
329 'preview.reload': '↻ 重载预览',
330 'preview.no_hv_text': '当前帧没有可编辑的文字(HTML 缺 data-hv-text 标签)。',
331
332 'frames.label': '分镜',
333 'frames.view_graph': '看图谱',
334 'frames.enhance': '⚡ Remotion',
335 'frames.enhance_hint': '用原生 Remotion 渲染这个数据帧(数字滚动、柱子生长)',
336 'frames.enhanced_revert': '⚡ Remotion ✓(还原)',
337 'frames.enhancing': '⚡ {pct}%…',
338 'enhance.done': '⚡ 该帧已用 Remotion 渲染',
339 'enhance.failed': '⚡ Remotion 失败:{message}',
340
341 'text_pane.title': '帧文字',
342 'text_pane.no_project': '无项目。',
343 'text_pane.empty_with_frames':
344 '当前帧没有可编辑文字。切到别的帧,或在画面里点 ✎ 编辑文字。',
345 'text_pane.empty_no_frames':
346 '还没有可编辑的字段。发一条消息生成第一版 HTML,逐帧字段会出现在这里。',
347 'text_pane.collapse': '收起面板',
348 'text_pane.save_state.idle': '—',
349 'text_pane.save_state.typing': '输入中…',
350 'text_pane.save_state.saving': '保存中…',
351 'text_pane.save_state.saved': '已保存',
352 'text_pane.save_state.error': '错误',
353
354 'export.starting': '⏵ 开始导出 MP4…',
355 'export.button_running': '⏵ {pct}% · {stage}',
356 'export.done_seconds': '✓ MP4 已导出 · {seconds}',
357 'export.done_no_seconds': '✓ MP4 已导出',
358 'export.failed': '⚠️ 导出失败:{message}',
359 'export.stream_interrupted': '导出流中断:{message}',
360 'export.failed_short': '导出失败:{message}',
361 'export.title': '🎬 MP4 已就绪',
362 'export.reveal': '在 Finder 中显示',
363 'export.copy_path': '复制路径',
364 'export.copied': '已复制路径',
365 'export.copy_failed': '复制失败:{message}',
366 'export.reveal_failed': '打开失败:{message}',
367
368 'soundtrack.title': '🎵 添加背景音乐和配音',
369 'soundtrack.summary_sub': 'AI 配乐 + 旁白,导出时自动混入',
370 'soundtrack.optional': '可选',
371 'soundtrack.hint': '背景音乐 + 旁白,导出时混入 MP4。',
372 'soundtrack.music_label': '背景音乐',
373 'soundtrack.music_placeholder': '选上面的风格,或自己描述 —— 风格、情绪、节奏',
374 'soundtrack.preset_energetic': '动感',
375 'soundtrack.preset_calm': '舒缓',
376 'soundtrack.preset_tech': '科技',
377 'soundtrack.preset_narrative': '叙事',
378 'soundtrack.preset_minimal': '极简',
379 'soundtrack.preset_epic': '史诗',
380 'soundtrack.narration_label': '旁白 / 配音',
381 'soundtrack.step_write': '第一步 · 写旁白文字',
382 'soundtrack.step_voice': '第二步 · 合成配音(音频)',
383 'soundtrack.editing_frame': '· 正在编辑第 {n}/{total} 帧',
384 'soundtrack.draft_frame': '✨ AI 起草本页',
385 'soundtrack.draft_all': '✨ AI 起草全部',
386 'soundtrack.gen_music': '🎵 生成配乐',
387 'soundtrack.gen_narration': '🎙 合成配音',
388 'soundtrack.empty_music': '请先选或描述一个音乐风格。',
389 'soundtrack.empty_narration': '请先填旁白文字(点 ✨ 可让 AI 起草)。',
390 'soundtrack.frame_word': '画面',
391 'soundtrack.total_word': '总时长',
392 'soundtrack.drafting': '生成中…',
393 'soundtrack.draft_need_frames': '请先生成视频',
394 'soundtrack.draft_failed': '⚠️ 生成失败:{message}',
395 'soundtrack.narration_placeholder': '每帧一句——点 ✨ 根据视频生成(留空则不加)',
396 'soundtrack.voice_label': '音色',
397 'soundtrack.voice_male_warm': '男声 · 温暖',
398 'soundtrack.voice_male_pro': '男声 · 专业',
399 'soundtrack.voice_male_deep': '男声 · 低沉',
400 'soundtrack.voice_female_anchor': '女声 · 播音',
401 'soundtrack.voice_female_mature': '女声 · 御姐',
402 'soundtrack.voice_female_sweet': '女声 · 甜美',
403 'soundtrack.fit_durations': '⇄ 时长适配配音',
404 'soundtrack.fit_hint': '按每帧旁白长短重新分配各帧时长',
405 'soundtrack.fitting': '适配中…',
406 'soundtrack.fitted': '✓ 帧时长已适配配音 · 共 {sec}s',
407 'soundtrack.fit_failed': '适配失败',
408 'soundtrack.music_volume': '音乐音量',
409 'soundtrack.narration_volume': '旁白音量',
410 'soundtrack.generate': '生成配乐',
411 'soundtrack.generating': '生成中…',
412 'soundtrack.clear': '清除',
413 'soundtrack.starting': '⏵ 正在生成配乐…',
414 'soundtrack.progress_music': '⏵ 正在生成背景音乐…',
415 'soundtrack.progress_narration': '⏵ 正在生成旁白…',
416 'soundtrack.done': '✓ 配乐就绪 —— 导出时会自动混入',
417 'soundtrack.failed': '⚠️ 配乐失败:{message}',
418 'soundtrack.music_ready': '音乐',
419 'soundtrack.narration_ready': '旁白',
420 'soundtrack.empty': '请先填写音乐提示词或旁白文字。',
421
422 'graph.title': '内容图谱',
423 'graph.download': '⬇ 下载 JSON',
424 'graph.close': '✕',
425 'graph.empty': '(项目没有图谱)',
426 'graph.error': '加载图谱失败:{message}',
427
428 'gallery.title': '挑一个模板',
429 'gallery.close': '✕',
430
431 'modal.new.title': '新建项目',
432 'modal.new.name_label': '名称',
433 'modal.new.name_placeholder': '例如:nexu-io 发布预告',
434 'modal.new.intent_label': '意图(可选)',
435 'modal.new.intent_placeholder': '一句话说说这个视频在讲什么',
436 'modal.new.cancel': '取消',
437 'modal.new.create': '创建',
438 'modal.new.name_required': '名称不能空',
439 'modal.new.created': '已创建 "{name}"',
440 'modal.new.failed': '创建项目失败',
441
442 'language.label': '语言',
443
444 'settings.title': '设置',
445 'settings.tab.agent': 'Agent',
446 'settings.tab.audio': '音频',
447 'settings.tab.language': '界面语言',
448 'settings.tab.about': '关于',
449
450 'settings.audio.title': '音频 · MiniMax',
451 'settings.audio.subtitle': '配乐生成(背景音乐 + 旁白)所需的 API key。',
452 'settings.audio.loading': '检查中…',
453 'settings.audio.api_key': 'API key',
454 'settings.audio.api_key_placeholder': '粘贴你的 MiniMax API key',
455 'settings.audio.region': 'API 区域',
456 'settings.audio.region_intl': '🌐 国际 · api.minimax.io',
457 'settings.audio.region_cn': '🇨🇳 国内 · api.minimaxi.com',
458 'settings.audio.base_url': 'Base URL',
459 'settings.audio.save': '保存',
460 'settings.audio.clear': '清除',
461 'settings.audio.configured': '✓ 已配置 · {key} · 来自{source}',
462 'settings.audio.not_configured': '尚未配置 MiniMax key。',
463 'settings.audio.source_config': '设置',
464 'settings.audio.source_env': '环境变量',
465 'settings.audio.saving': '保存中…',
466 'settings.audio.saved': '✓ 已保存',
467 'settings.audio.save_failed': '保存失败:{message}',
468 'settings.audio.need_key': '请先填写 API key。',
469 'settings.audio.hint': '保存在本地 .html-video/media-config.json。请按你的 key 选对区域——国际版 api.minimax.io 的 key 在国内版 api.minimaxi.com 上无法使用,反之亦然;旧的 api.minimaxi.chat 域名已停用。',
470
471 'settings.agent.title': 'Agent',
472 'settings.agent.subtitle': '选一个运行时把你的对话翻成 HTML。',
473 'settings.agent.mode.local': '本机 CLI',
474 'settings.agent.mode.byok': 'BYOK (API)',
475 'settings.agent.detected': '已检测到的 agent({count})',
476 'settings.agent.test': '测试',
477 'settings.agent.testing': '测试中…',
478 'settings.agent.test_ok': '通过 · {ms}ms · {bytes}B',
479 'settings.agent.test_fail': '失败:{message}',
480 'settings.agent.empty_reply': '失败:agent 返回为空',
481 'settings.agent.use': '使用',
482 'settings.agent.in_use': '当前',
483 'settings.agent.unavailable': '未安装',
484 'agent.sign_in': '登录',
485 'agent.signing_in': '登录中…',
486 'agent.signed_in': '✓ 已登录 AMR',
487 'agent.sign_in_failed': '登录失败',
488 'agent.recommended': '推荐 · 一次登录,多种模型',
489 'settings.agent.byok.intro': '直连 Anthropic / OpenRouter API。从环境变量读:',
490 'settings.agent.byok.env_key': 'ANTHROPIC_API_KEY 或 ANTHROPIC_AUTH_TOKEN',
491 'settings.agent.byok.env_base': 'ANTHROPIC_BASE_URL(可选,默认 api.anthropic.com)',
492 'settings.agent.rescan': '↻ 重新扫描',
493 'settings.agent.rescanned': '已重新扫描',
494
495 'settings.language.title': '界面语言',
496 'settings.language.subtitle': 'Studio 界面语言,切换立即生效。',
497 'settings.language.en': 'English',
498 'settings.language.zh': '中文',
499 'settings.language.en_sub': 'EN',
500 'settings.language.zh_sub': 'ZH-CN',
501
502 'settings.about.title': '关于',
503 'settings.about.subtitle': 'html-video — 开源的 HTML→视频 meta-layer,为 coding agent 设计。',
504 'settings.about.version': '版本',
505 'settings.about.repo': '代码仓库',
506 'settings.about.discord': 'Discord',
507 'settings.about.license': '许可',
508 'settings.about.related': '相关项目',
509
510 'toolbar.settings': '设置',
511
512 'tpl_preview.cancel': '取消',
513 'tpl_preview.use': '使用此模板',
514 'tpl_preview.replace_confirm': '把当前模板替换为 "{name}"?现有预览不会被覆盖,下一轮 chat 时 agent 会按新模板重写。',
515 'tpl_preview.applied': '已切换模板:{name}',
516 'tpl_preview.fps_dur': '{fps}fps · {duration}秒 · {aspect}',
517 'tpl_preview.source_skill': '改编自',
518 'tpl_preview.source_origin': '设计渊源',
519 'tpl_preview.source_license': '许可证',
520 },
521 };
522
523 const STORAGE_KEY = 'hv.studio.locale';
524 let _locale = resolveInitialLocale();
525
526 function resolveInitialLocale() {
527 try {
528 const stored = localStorage.getItem(STORAGE_KEY);
529 if (stored && AVAILABLE_LOCALES.includes(stored)) return stored;
530 } catch {
531 /* localStorage unavailable */
532 }
533 // Default is English regardless of nav.language. Joey explicitly asked.
534 return DEFAULT_LOCALE;
535 }
536
537 export function getLocale() {
538 return _locale;
539 }
540
541 export function setLocale(loc) {
542 if (!AVAILABLE_LOCALES.includes(loc)) return;
543 _locale = loc;
544 try { localStorage.setItem(STORAGE_KEY, loc); } catch {}
545 // Notify listeners (the studio app re-renders).
546 document.dispatchEvent(new CustomEvent('hv-locale-change', { detail: { locale: loc } }));
547 }
548
549 /**
550 * Apply i18n to static DOM elements. Markers:
551 * data-i18n="key" → textContent
552 * data-i18n-attr="placeholder:key,title:key2" → set those attrs
553 * data-i18n-html="key" → innerHTML (caution: only for trusted keys)
554 *
555 * Call once after DOMContentLoaded and also on every locale change.
556 */
557 export function applyDomI18n(root) {
558 const r = root || document;
559 r.querySelectorAll('[data-i18n]').forEach((el) => {
560 el.textContent = t(el.dataset.i18n);
561 });
562 r.querySelectorAll('[data-i18n-html]').forEach((el) => {
563 el.innerHTML = t(el.dataset.i18nHtml);
564 });
565 r.querySelectorAll('[data-i18n-attr]').forEach((el) => {
566 const pairs = (el.dataset.i18nAttr || '').split(',').map((s) => s.trim()).filter(Boolean);
567 for (const pair of pairs) {
568 const [attr, key] = pair.split(':').map((s) => s.trim());
569 if (attr && key) el.setAttribute(attr, t(key));
570 }
571 });
572 }
573
574 document.addEventListener('hv-locale-change', () => applyDomI18n());
575 document.addEventListener('DOMContentLoaded', () => applyDomI18n());
576
577 /**
578 * Translate a key. `params` is a plain object whose keys substitute
579 * `{key}` placeholders in the resolved string.
580 */
581 export function t(key, params) {
582 const dict = DICT[_locale] ?? DICT[DEFAULT_LOCALE];
583 let s = dict[key];
584 if (s === undefined) {
585 // Fall back to English, then to the key itself.
586 s = DICT[DEFAULT_LOCALE][key] ?? key;
587 }
588 if (params) {
589 for (const [k, v] of Object.entries(params)) {
590 s = s.replaceAll(`{${k}}`, String(v));
591 }
592 }
593 return s;
594 }
595
595 lines JAVASCRIPT