| 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 |