ffai-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ffai/executor.py ADDED
@@ -0,0 +1,255 @@
1
+ """Safe subprocess execution with live progress and interrupt handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import re
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import time
12
+ from collections import deque
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import TextIO
17
+
18
+ from rich.console import Console
19
+ from rich.progress import (
20
+ BarColumn,
21
+ Progress,
22
+ SpinnerColumn,
23
+ TaskProgressColumn,
24
+ TextColumn,
25
+ TimeElapsedColumn,
26
+ )
27
+
28
+ from ffai.errors import ExecutionError
29
+ from ffai.utils import scrubbed_environment
30
+
31
+ _TIME_PATTERN = re.compile(r"\btime=(\d+):(\d{2}):(\d{2}(?:\.\d+)?)")
32
+ _TAIL_LINES = 30
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ExecutionResult:
37
+ exit_code: int | None
38
+ interrupted: bool
39
+ elapsed_seconds: float
40
+ stderr_tail: str
41
+ output_path: str
42
+
43
+ @property
44
+ def succeeded(self) -> bool:
45
+ return not self.interrupted and self.exit_code == 0
46
+
47
+
48
+ def parse_progress_seconds(line: str) -> float | None:
49
+ """Parse FFmpeg's ``time=HH:MM:SS.xx`` stats field."""
50
+
51
+ match = _TIME_PATTERN.search(line)
52
+ if match is None:
53
+ return None
54
+ hours, minutes, seconds = match.groups()
55
+ return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
56
+
57
+
58
+ def execute_tool(
59
+ args: Sequence[str],
60
+ *,
61
+ expected_output: str,
62
+ console: Console,
63
+ tool_label: str,
64
+ total_duration: float | None = None,
65
+ show_progress: bool = False,
66
+ ) -> ExecutionResult:
67
+ """Run a prebuilt argv list and report the real process outcome.
68
+
69
+ Stderr is consumed continuously so the pipe cannot deadlock. When
70
+ ``show_progress`` is enabled, FFmpeg-style ``time=HH:MM:SS.xx`` stats lines
71
+ drive a Rich progress bar. Without progress the bar is skipped entirely and
72
+ only the tail of stderr is kept for diagnostics. Ctrl+C terminates the whole
73
+ child process group on POSIX and is reported separately from a normal
74
+ non-zero exit.
75
+ """
76
+
77
+ if not args:
78
+ raise ExecutionError("No command to execute")
79
+ if shutil.which(args[0]) is None:
80
+ raise ExecutionError(
81
+ f"{tool_label} was not found on PATH. Install {tool_label} and try again."
82
+ )
83
+
84
+ popen_kwargs: dict[str, object] = {}
85
+ if os.name == "posix":
86
+ # Keep the child in its own process group so cancellation can reliably
87
+ # reach the tool without leaving orphaned workers.
88
+ popen_kwargs["start_new_session"] = True
89
+
90
+ try:
91
+ process = subprocess.Popen(
92
+ list(args),
93
+ stdin=subprocess.DEVNULL,
94
+ stdout=subprocess.DEVNULL,
95
+ stderr=subprocess.PIPE,
96
+ text=True,
97
+ encoding="utf-8",
98
+ errors="replace",
99
+ bufsize=1,
100
+ env=scrubbed_environment(),
101
+ **popen_kwargs,
102
+ )
103
+ except OSError as exc:
104
+ raise ExecutionError(f"Could not start {tool_label}: {exc}") from exc
105
+
106
+ assert process.stderr is not None
107
+ started = time.monotonic()
108
+ interrupted = False
109
+ tail: deque[str] = deque(maxlen=200)
110
+ duration = total_duration if total_duration and total_duration > 0 else None
111
+
112
+ progress: Progress | None = None
113
+ if show_progress:
114
+ progress = Progress(
115
+ SpinnerColumn(),
116
+ TextColumn("[bold cyan]{task.description}"),
117
+ BarColumn(),
118
+ TaskProgressColumn(),
119
+ TimeElapsedColumn(),
120
+ console=console,
121
+ transient=True,
122
+ )
123
+
124
+ try:
125
+ if progress is None:
126
+ _consume_stderr(
127
+ process.stderr,
128
+ tail,
129
+ progress=None,
130
+ task_id=None,
131
+ duration=None,
132
+ )
133
+ else:
134
+ with progress:
135
+ task_id = progress.add_task(f"Encoding with {tool_label}", total=duration)
136
+ _consume_stderr(
137
+ process.stderr,
138
+ tail,
139
+ progress=progress,
140
+ task_id=task_id,
141
+ duration=duration,
142
+ )
143
+ except KeyboardInterrupt:
144
+ interrupted = True
145
+ _terminate_process(process)
146
+ finally:
147
+ if process.poll() is None:
148
+ _terminate_process(process)
149
+ # The pipe is already closed when process teardown raced with EOF;
150
+ # there is nothing left to release.
151
+ with contextlib.suppress(OSError):
152
+ process.stderr.close()
153
+ return_code = process.poll()
154
+ if return_code is None:
155
+ try:
156
+ return_code = process.wait(timeout=5)
157
+ except subprocess.TimeoutExpired:
158
+ return_code = None
159
+ elapsed = time.monotonic() - started
160
+
161
+ result = ExecutionResult(
162
+ exit_code=return_code,
163
+ interrupted=interrupted,
164
+ elapsed_seconds=elapsed,
165
+ stderr_tail="\n".join(tail),
166
+ output_path=expected_output,
167
+ )
168
+
169
+ if result.succeeded:
170
+ output = Path(expected_output)
171
+ if not output.is_file():
172
+ raise ExecutionError(
173
+ f"{tool_label} exited successfully but did not create the output file.\n"
174
+ + (result.stderr_tail or "No diagnostics were produced.")
175
+ )
176
+ try:
177
+ empty = output.stat().st_size == 0
178
+ except OSError as exc:
179
+ raise ExecutionError(f"Could not inspect the output file: {exc}") from exc
180
+ if empty:
181
+ raise ExecutionError(
182
+ f"{tool_label} exited successfully but the output file is empty."
183
+ )
184
+
185
+ return result
186
+
187
+
188
+ def execute_ffmpeg(
189
+ args: Sequence[str],
190
+ *,
191
+ expected_output: str,
192
+ console: Console,
193
+ total_duration: float | None = None,
194
+ ) -> ExecutionResult:
195
+ """Run FFmpeg with its stats-driven progress bar."""
196
+
197
+ return execute_tool(
198
+ args,
199
+ expected_output=expected_output,
200
+ console=console,
201
+ tool_label="FFmpeg",
202
+ total_duration=total_duration,
203
+ show_progress=True,
204
+ )
205
+
206
+
207
+ def _consume_stderr(
208
+ stream: TextIO,
209
+ tail: deque[str],
210
+ *,
211
+ progress: Progress | None,
212
+ task_id: int | None,
213
+ duration: float | None,
214
+ ) -> None:
215
+ """Drain stderr line by line, keeping the tail and feeding the progress bar."""
216
+
217
+ for raw_line in stream:
218
+ line = raw_line.strip()
219
+ if line:
220
+ tail.append(line)
221
+ if progress is None or task_id is None:
222
+ continue
223
+ seconds = parse_progress_seconds(raw_line)
224
+ if seconds is None:
225
+ continue
226
+ if duration is not None:
227
+ progress.update(task_id, completed=min(seconds, duration))
228
+ else:
229
+ progress.update(task_id, advance=0)
230
+
231
+
232
+ def _terminate_process(process: subprocess.Popen[str]) -> None:
233
+ if process.poll() is not None:
234
+ return
235
+
236
+ if os.name == "posix":
237
+ try:
238
+ os.killpg(process.pid, signal.SIGTERM)
239
+ except ProcessLookupError:
240
+ # The child exited between poll() and the signal; it is already gone.
241
+ return
242
+ else:
243
+ process.terminate()
244
+
245
+ try:
246
+ process.wait(timeout=5)
247
+ except subprocess.TimeoutExpired:
248
+ if os.name == "posix":
249
+ try:
250
+ os.killpg(process.pid, signal.SIGKILL)
251
+ except ProcessLookupError:
252
+ return
253
+ else:
254
+ process.kill()
255
+ process.wait()
ffai/i18n.py ADDED
@@ -0,0 +1,400 @@
1
+ """Small, dependency-free localization layer for the interactive UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from typing import Literal
8
+
9
+ Language = Literal["en", "zh"]
10
+
11
+ _CATALOGS: dict[Language, dict[str, str]] = {
12
+ "en": {
13
+ "banner.tagline": "Natural-language interface for media and document conversion",
14
+ "loop.quit_hint": "Press Ctrl+D or type 'exit' to quit.",
15
+ "loop.goodbye": "Goodbye.",
16
+ "loop.interrupt_kept": "Interrupted. The current task was kept.",
17
+ "status.thinking": "Thinking...",
18
+ "error.input_title": "Input error",
19
+ "error.title": "Error",
20
+ "error.cannot_build_title": "Cannot build command",
21
+ "error.config_title": "Configuration error",
22
+ "error.invalid_config_title": "Invalid configuration",
23
+ "error.invalid_base_url_title": "Invalid base URL",
24
+ "error.config_required_title": "Configuration required",
25
+ "startup.api_label": "API: ",
26
+ "startup.api_value": "{provider} / {model} at {endpoint}",
27
+ "startup.tool_missing": (
28
+ "{tool} was not found on PATH. Install it before running its commands."
29
+ ),
30
+ "startup.hint": (
31
+ "Type 'help' for commands, or describe a media or document conversion in "
32
+ "natural language. 'exit' quits."
33
+ ),
34
+ "startup.setup_required_intro": (
35
+ "ffai requires an OpenAI-compatible API configuration.\n\n"
36
+ ),
37
+ "startup.missing_label": "Missing: ",
38
+ "startup.setup_hint": ("\n\nComplete the setup below. Ctrl+C cancels startup."),
39
+ "startup.setup_cancelled": "API configuration was not completed; ffai cannot start.",
40
+ "startup.noninteractive": (
41
+ "API configuration is required before ffai can start. Missing: {missing}. "
42
+ "Set the environment variables or run ffai in an interactive terminal to "
43
+ "use the setup wizard."
44
+ ),
45
+ "action.added": "Added input: {path}",
46
+ "action.duplicate": "Already added: {path}",
47
+ "action.add_tip": (
48
+ "Tip: set an output with 'setoutput <file>', then describe the operation."
49
+ ),
50
+ "action.no_files": "No files were added.",
51
+ "action.output_set": "Output set: {path}",
52
+ "action.reset": "Task state cleared.",
53
+ "action.config_saved": "API configuration saved to {path}",
54
+ "action.tool_set": "Conversion tool set to {tool}.",
55
+ "action.tool_unchanged": "{tool} is already the active tool.",
56
+ "action.tool_detected": (
57
+ "Detected {tool} from the input file type; the tool was selected automatically."
58
+ ),
59
+ "config.show_hint": "Use 'config set' to update the API connection settings.",
60
+ "config.title": "[bold]API configuration[/bold]",
61
+ "config.provider": "Provider",
62
+ "config.api_key": "API key",
63
+ "config.model": "Model",
64
+ "config.base_url": "Base URL",
65
+ "config.config_file": "Config file",
66
+ "config.file_not_created": "{path} (not created)",
67
+ "config.openai_default": "(OpenAI default)",
68
+ "config.setup_required_title": "[bold yellow]API setup required[/bold yellow]",
69
+ "config.wizard_intro": "Configure the API connection.\n",
70
+ "config.wizard_hint": (
71
+ "Press Enter to keep a current value. Choose a provider by number or ID.\n"
72
+ ),
73
+ "config.save_location": "Settings are saved to {path}.",
74
+ "config.setup_cancelled": "Configuration cancelled; no changes saved.",
75
+ "config.interactive_required": "Interactive setup requires a terminal.",
76
+ "config.column_number": "#",
77
+ "config.column_provider": "Provider",
78
+ "config.column_base_url": "Base URL",
79
+ "config.column_model": "Suggested model",
80
+ "config.current_marker": "{name} (current)",
81
+ "config.custom_url_placeholder": "(enter a custom URL)",
82
+ "config.custom_model_placeholder": "(enter a model)",
83
+ "config.docs_label": "Docs: ",
84
+ "config.api_keys_label": "API keys: ",
85
+ "config.provider_prompt_keep": ("Provider (Enter keeps {current}) (number or ID): "),
86
+ "config.provider_prompt_new": "Provider (1-{count}) (number or ID): ",
87
+ "config.provider_required": "Choose a provider.",
88
+ "config.provider_unknown": "Unknown provider. Enter a listed number or provider ID.",
89
+ "config.api_key_prompt_keep": "{provider} API key (Enter keeps current): ",
90
+ "config.api_key_prompt_new": "{provider} API key: ",
91
+ "config.api_key_required": "{provider} API key is required.",
92
+ "config.model_prompt_keep": "Model (Enter keeps current): ",
93
+ "config.model_prompt_default": "Model (default: {model}): ",
94
+ "config.model_prompt_new": "Model: ",
95
+ "config.model_required": "Model is required.",
96
+ "config.base_url_prompt_keep": "Base URL (required; Enter keeps current): ",
97
+ "config.base_url_prompt_new": "Base URL (required): ",
98
+ "config.base_url_required": "Base URL is required for a custom provider.",
99
+ "config.invalid_configuration": "Invalid configuration",
100
+ "config.not_set": "(not set)",
101
+ "help.title": "[bold]ffai help[/bold]",
102
+ "help.command": "Command",
103
+ "help.description": "Description",
104
+ "help.add": "Add one or more input files",
105
+ "help.tool": "Switch the conversion tool (ffmpeg or pandoc)",
106
+ "help.setoutput": "Set the output file",
107
+ "help.show": "Show the current task state",
108
+ "help.preview": "Build and display the command without running it",
109
+ "help.exec": "Build, confirm, and run the command",
110
+ "help.reset": "Clear the task state",
111
+ "help.config": "Show or update the API configuration",
112
+ "help.help": "Show this help",
113
+ "help.exit": "Exit ffai",
114
+ "help.examples_title": "Natural language examples\n",
115
+ "help.example_crop": " crop the video to 1920x1080, centered\n",
116
+ "help.example_codec": " use H.265 with CRF 23\n",
117
+ "help.example_audio": " audio AAC at 192k\n",
118
+ "help.example_doc": " convert notes.md to report.pdf with a table of contents\n",
119
+ "help.example_pdf": " combine two documents into one PDF\n\n",
120
+ "help.llm_note": (
121
+ "Unrecognized input is sent to the LLM. It updates the structured task state "
122
+ "of the selected tool; it never executes commands."
123
+ ),
124
+ "help.aliases": "Chinese aliases: 预览, 显示, 执行, 重置, 配置, 工具, 帮助, 退出",
125
+ "task.title": "[bold]{tool} task[/bold] [dim]revision {revision}[/dim]",
126
+ "task.inputs": "Inputs",
127
+ "task.output": "Output",
128
+ "task.tool": "Tool",
129
+ "task.description": "Description",
130
+ "task.default_description": "New {tool} task",
131
+ "task.none": "(none)",
132
+ "task.not_set": "(not set)",
133
+ "task.yes": "Yes",
134
+ "task.no": "No",
135
+ "field.video_filters": "Video filters",
136
+ "field.audio_filters": "Audio filters",
137
+ "field.filter_complex": "Filter complex",
138
+ "field.maps": "Stream maps",
139
+ "field.video_codec": "Video codec",
140
+ "field.audio_codec": "Audio codec",
141
+ "field.crf": "CRF",
142
+ "field.preset": "Preset",
143
+ "field.video_bitrate": "Video bitrate",
144
+ "field.audio_bitrate": "Audio bitrate",
145
+ "field.extra_options": "Extra options",
146
+ "field.description": "Description",
147
+ "field.tool": "Tool",
148
+ "field.from_format": "From format",
149
+ "field.to_format": "To format",
150
+ "field.standalone": "Standalone",
151
+ "field.toc": "Table of contents",
152
+ "field.toc_depth": "TOC depth",
153
+ "field.number_sections": "Number sections",
154
+ "field.shift_heading_level_by": "Shift heading level",
155
+ "field.metadata": "Metadata",
156
+ "field.template": "Template",
157
+ "field.reference_doc": "Reference document",
158
+ "field.pdf_engine": "PDF engine",
159
+ "field.highlight_style": "Highlight style",
160
+ "field.embed_resources": "Embed resources",
161
+ "field.citeproc": "Citations (citeproc)",
162
+ "field.bibliography": "Bibliography",
163
+ "field.csl": "CSL style",
164
+ "field.resource_path": "Resource path",
165
+ "changes.title": "Updated fields",
166
+ "changes.field": "Field",
167
+ "changes.before": "Before",
168
+ "changes.after": "After",
169
+ "changes.hint": "Run 'show' or 'preview' to inspect the updated task.",
170
+ "changes.none": "The model proposed no effective task changes.",
171
+ "decision.question_title": "[bold yellow]Question[/bold yellow]",
172
+ "decision.clarification_title": "[bold yellow]Need clarification[/bold yellow]",
173
+ "panel.preview_title": "[bold]Preview[/bold] [dim]not executed[/dim]",
174
+ "panel.exec_title": "[bold]Command to execute[/bold]",
175
+ "panel.overwrite_warning": (
176
+ "Warning: the output file already exists and will be overwritten."
177
+ ),
178
+ "panel.confirm_execute": "Execute this command?",
179
+ "execution.cancelled": "Execution cancelled.",
180
+ "execution.interrupted": "Execution interrupted.",
181
+ "execution.interrupted_partial": (
182
+ "Execution interrupted. A partial output file may exist and can be removed."
183
+ ),
184
+ "execution.done_title": "[bold green]Done[/bold green]",
185
+ "execution.success": "Command completed successfully.\n",
186
+ "execution.output_label": "Output: ",
187
+ "execution.elapsed": "\nElapsed: {seconds:.1f}s",
188
+ "execution.failed_title": "[bold red]{tool} failed[/bold red]",
189
+ "execution.exit_code": "{tool} exited with code {code}.",
190
+ "cli.interrupted": "Interrupted. Goodbye.",
191
+ "cli.config_error_title": "[bold red]Configuration error[/bold red]",
192
+ "cli.config_required_title": "[bold red]Configuration required[/bold red]",
193
+ },
194
+ "zh": {
195
+ "banner.tagline": "媒体与文档转换的自然语言界面",
196
+ "loop.quit_hint": "按 Ctrl+D 或输入 'exit' 退出。",
197
+ "loop.goodbye": "再见。",
198
+ "loop.interrupt_kept": "已中断,当前任务已保留。",
199
+ "status.thinking": "思考中...",
200
+ "error.input_title": "输入错误",
201
+ "error.title": "错误",
202
+ "error.cannot_build_title": "无法构建命令",
203
+ "error.config_title": "配置错误",
204
+ "error.invalid_config_title": "配置无效",
205
+ "error.invalid_base_url_title": "Base URL 无效",
206
+ "error.config_required_title": "需要配置",
207
+ "startup.api_label": "API:",
208
+ "startup.api_value": "{provider} / {model} · {endpoint}",
209
+ "startup.tool_missing": "未在 PATH 中找到 {tool},请先安装再执行其命令。",
210
+ "startup.hint": (
211
+ "输入 'help' 查看命令,或直接用自然语言描述媒体或文档转换需求;输入 'exit' 退出。"
212
+ ),
213
+ "startup.setup_required_intro": "ffai 需要配置一个 OpenAI 兼容的 API。\n\n",
214
+ "startup.missing_label": "缺少:",
215
+ "startup.setup_hint": "\n\n请在下方完成配置。按 Ctrl+C 取消启动。",
216
+ "startup.setup_cancelled": "API 配置未完成,ffai 无法启动。",
217
+ "startup.noninteractive": (
218
+ "ffai 启动前必须完成 API 配置。缺少:{missing}。"
219
+ "请设置环境变量,或在交互式终端中运行 ffai 以使用配置向导。"
220
+ ),
221
+ "action.added": "已添加输入:{path}",
222
+ "action.duplicate": "已经添加过:{path}",
223
+ "action.add_tip": "提示:使用 'setoutput <文件>' 设置输出,然后描述你的处理需求。",
224
+ "action.no_files": "没有添加任何文件。",
225
+ "action.output_set": "输出已设置:{path}",
226
+ "action.reset": "任务状态已清空。",
227
+ "action.config_saved": "API 配置已保存至 {path}",
228
+ "action.tool_set": "转换工具已设置为 {tool}。",
229
+ "action.tool_unchanged": "{tool} 已是当前工具。",
230
+ "action.tool_detected": "已根据输入文件类型自动选择 {tool}。",
231
+ "config.show_hint": "使用 'config set' 更新 API 连接配置。",
232
+ "config.title": "[bold]API 配置[/bold]",
233
+ "config.provider": "提供商",
234
+ "config.api_key": "API 密钥",
235
+ "config.model": "模型",
236
+ "config.base_url": "Base URL",
237
+ "config.config_file": "配置文件",
238
+ "config.file_not_created": "{path}(尚未创建)",
239
+ "config.openai_default": "(OpenAI 默认)",
240
+ "config.setup_required_title": "[bold yellow]需要配置 API[/bold yellow]",
241
+ "config.wizard_intro": "配置 API 连接。\n",
242
+ "config.wizard_hint": "按回车保留当前值。通过序号或 ID 选择提供商。\n",
243
+ "config.save_location": "配置将保存到 {path}。",
244
+ "config.setup_cancelled": "已取消配置,未保存任何更改。",
245
+ "config.interactive_required": "交互式配置需要在终端中运行。",
246
+ "config.column_number": "序号",
247
+ "config.column_provider": "提供商",
248
+ "config.column_base_url": "Base URL",
249
+ "config.column_model": "建议模型",
250
+ "config.current_marker": "{name}(当前)",
251
+ "config.custom_url_placeholder": "(请输入自定义 URL)",
252
+ "config.custom_model_placeholder": "(请输入模型)",
253
+ "config.docs_label": "文档:",
254
+ "config.api_keys_label": "API 密钥:",
255
+ "config.provider_prompt_keep": "提供商(回车保留 {current})(序号或 ID):",
256
+ "config.provider_prompt_new": "提供商(1-{count},序号或 ID):",
257
+ "config.provider_required": "请选择提供商。",
258
+ "config.provider_unknown": "未知提供商,请输入列表中的序号或提供商 ID。",
259
+ "config.api_key_prompt_keep": "{provider} API Key(回车保留当前值):",
260
+ "config.api_key_prompt_new": "{provider} API Key:",
261
+ "config.api_key_required": "需要填写 {provider} API Key。",
262
+ "config.model_prompt_keep": "模型(回车保留当前值):",
263
+ "config.model_prompt_default": "模型(默认:{model}):",
264
+ "config.model_prompt_new": "模型:",
265
+ "config.model_required": "需要填写模型名称。",
266
+ "config.base_url_prompt_keep": "Base URL(必填,回车保留当前值):",
267
+ "config.base_url_prompt_new": "Base URL(必填):",
268
+ "config.base_url_required": "自定义提供商必须填写 Base URL。",
269
+ "config.invalid_configuration": "配置无效",
270
+ "config.not_set": "(未设置)",
271
+ "help.title": "[bold]ffai 帮助[/bold]",
272
+ "help.command": "命令",
273
+ "help.description": "说明",
274
+ "help.add": "添加一个或多个输入文件",
275
+ "help.tool": "切换转换工具(ffmpeg 或 pandoc)",
276
+ "help.setoutput": "设置输出文件",
277
+ "help.show": "显示当前任务状态",
278
+ "help.preview": "构建并显示命令,但不执行",
279
+ "help.exec": "构建命令,确认后执行",
280
+ "help.reset": "清空当前任务状态",
281
+ "help.config": "查看或更新 API 配置",
282
+ "help.help": "显示帮助",
283
+ "help.exit": "退出 ffai",
284
+ "help.examples_title": "自然语言示例\n",
285
+ "help.example_crop": " 把视频裁剪到 1920x1080,保持中央构图\n",
286
+ "help.example_codec": " 使用 H.265 编码,CRF 23\n",
287
+ "help.example_audio": " 音频使用 AAC,码率 192k\n",
288
+ "help.example_doc": " 把 notes.md 转换为带目录的 report.pdf\n",
289
+ "help.example_pdf": " 把两个文档合并成一个 PDF\n\n",
290
+ "help.llm_note": (
291
+ "无法识别的输入会交给 LLM 解析。LLM 只更新当前工具的结构化任务状态,"
292
+ "不会执行命令。"
293
+ ),
294
+ "help.aliases": "中文命令别名:预览、显示、执行、重置、配置、工具、帮助、退出",
295
+ "task.title": "[bold]{tool} · 当前任务[/bold] [dim]版本 {revision}[/dim]",
296
+ "task.inputs": "输入文件",
297
+ "task.output": "输出文件",
298
+ "task.tool": "工具",
299
+ "task.description": "任务描述",
300
+ "task.default_description": "新的 {tool} 任务",
301
+ "task.none": "(无)",
302
+ "task.not_set": "(未设置)",
303
+ "task.yes": "是",
304
+ "task.no": "否",
305
+ "field.video_filters": "视频滤镜",
306
+ "field.audio_filters": "音频滤镜",
307
+ "field.filter_complex": "复杂滤镜图",
308
+ "field.maps": "流映射",
309
+ "field.video_codec": "视频编码器",
310
+ "field.audio_codec": "音频编码器",
311
+ "field.crf": "CRF",
312
+ "field.preset": "Preset",
313
+ "field.video_bitrate": "视频码率",
314
+ "field.audio_bitrate": "音频码率",
315
+ "field.extra_options": "额外参数",
316
+ "field.description": "任务描述",
317
+ "field.tool": "工具",
318
+ "field.from_format": "源格式",
319
+ "field.to_format": "目标格式",
320
+ "field.standalone": "独立文档",
321
+ "field.toc": "目录",
322
+ "field.toc_depth": "目录深度",
323
+ "field.number_sections": "章节编号",
324
+ "field.shift_heading_level_by": "标题级别偏移",
325
+ "field.metadata": "元数据",
326
+ "field.template": "模板",
327
+ "field.reference_doc": "参考文档",
328
+ "field.pdf_engine": "PDF 引擎",
329
+ "field.highlight_style": "代码高亮样式",
330
+ "field.embed_resources": "嵌入资源",
331
+ "field.citeproc": "引用处理(citeproc)",
332
+ "field.bibliography": "参考文献",
333
+ "field.csl": "CSL 样式",
334
+ "field.resource_path": "资源路径",
335
+ "changes.title": "已更新字段",
336
+ "changes.field": "字段",
337
+ "changes.before": "修改前",
338
+ "changes.after": "修改后",
339
+ "changes.hint": "使用 'show' 或 'preview' 查看更新后的任务。",
340
+ "changes.none": "模型没有产生有效的任务变更。",
341
+ "decision.question_title": "[bold yellow]问题[/bold yellow]",
342
+ "decision.clarification_title": "[bold yellow]需要澄清[/bold yellow]",
343
+ "panel.preview_title": "[bold]预览[/bold] [dim]未执行[/dim]",
344
+ "panel.exec_title": "[bold]待执行命令[/bold]",
345
+ "panel.overwrite_warning": "警告:输出文件已存在,执行时会覆盖该文件。",
346
+ "panel.confirm_execute": "执行此命令?",
347
+ "execution.cancelled": "已取消执行。",
348
+ "execution.interrupted": "执行已中断。",
349
+ "execution.interrupted_partial": "执行已中断。可能留下不完整的输出文件,可自行删除。",
350
+ "execution.done_title": "[bold green]完成[/bold green]",
351
+ "execution.success": "命令执行成功。\n",
352
+ "execution.output_label": "输出:",
353
+ "execution.elapsed": "\n耗时:{seconds:.1f}s",
354
+ "execution.failed_title": "[bold red]{tool} 执行失败[/bold red]",
355
+ "execution.exit_code": "{tool} 退出码为 {code}。",
356
+ "cli.interrupted": "已中断,再见。",
357
+ "cli.config_error_title": "[bold red]配置错误[/bold red]",
358
+ "cli.config_required_title": "[bold red]需要配置[/bold red]",
359
+ },
360
+ }
361
+
362
+
363
+ class Translator:
364
+ """Look up UI text for one language, falling back to English."""
365
+
366
+ def __init__(self, language: Language) -> None:
367
+ self.language = language
368
+
369
+ def __call__(self, key: str, **values: object) -> str:
370
+ template = _CATALOGS[self.language].get(key) or _CATALOGS["en"].get(key, key)
371
+ return template.format(**values)
372
+
373
+
374
+ def resolve_language(
375
+ explicit: str | None = None,
376
+ env: Mapping[str, str] | None = None,
377
+ ) -> Language:
378
+ """Resolve the UI language from a flag, ``FFAI_LANG``, or the locale."""
379
+
380
+ if explicit == "en":
381
+ return "en"
382
+ if explicit == "zh":
383
+ return "zh"
384
+ source = os.environ if env is None else env
385
+ configured = (source.get("FFAI_LANG") or "").strip().lower()
386
+ normalized = _normalize_language(configured)
387
+ if normalized is not None:
388
+ return normalized
389
+ locale_name = (
390
+ source.get("LC_ALL") or source.get("LC_MESSAGES") or source.get("LANG") or ""
391
+ ).lower()
392
+ return "zh" if locale_name.startswith("zh") else "en"
393
+
394
+
395
+ def _normalize_language(value: str) -> Language | None:
396
+ if value.startswith("zh"):
397
+ return "zh"
398
+ if value.startswith("en"):
399
+ return "en"
400
+ return None