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/__init__.py +5 -0
- ffai/__main__.py +5 -0
- ffai/cli.py +97 -0
- ffai/command_builder.py +287 -0
- ffai/config.py +251 -0
- ffai/errors.py +31 -0
- ffai/executor.py +255 -0
- ffai/i18n.py +400 -0
- ffai/llm.py +181 -0
- ffai/models.py +454 -0
- ffai/pandoc_builder.py +254 -0
- ffai/parser.py +112 -0
- ffai/prompt.py +296 -0
- ffai/providers.py +122 -0
- ffai/repl.py +933 -0
- ffai/state.py +291 -0
- ffai/tools.py +177 -0
- ffai/utils.py +91 -0
- ffai_cli-0.2.0.dist-info/METADATA +620 -0
- ffai_cli-0.2.0.dist-info/RECORD +24 -0
- ffai_cli-0.2.0.dist-info/WHEEL +5 -0
- ffai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ffai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ffai_cli-0.2.0.dist-info/top_level.txt +1 -0
ffai/state.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Stateful, locally owned conversion task session.
|
|
2
|
+
|
|
3
|
+
A session holds one task per conversion tool plus the currently selected tool.
|
|
4
|
+
Inputs and outputs are shared user-facing files for the active tool, and are
|
|
5
|
+
still changed exclusively through :meth:`SessionState.add_inputs` and
|
|
6
|
+
:meth:`SessionState.set_output`, which validate paths locally. LLM patches can
|
|
7
|
+
only update the technical fields of the tool they target.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from ffai.errors import StateError
|
|
20
|
+
from ffai.models import (
|
|
21
|
+
FFmpegPatch,
|
|
22
|
+
FFmpegTask,
|
|
23
|
+
PandocPatch,
|
|
24
|
+
PandocTask,
|
|
25
|
+
ToolName,
|
|
26
|
+
ToolPatch,
|
|
27
|
+
)
|
|
28
|
+
from ffai.tools import TOOL_SPECS, ToolSpec, detect_tool, get_tool
|
|
29
|
+
|
|
30
|
+
_LIST_PATCH_FIELDS = {
|
|
31
|
+
"video_filters",
|
|
32
|
+
"audio_filters",
|
|
33
|
+
"extra_options",
|
|
34
|
+
"maps",
|
|
35
|
+
"metadata",
|
|
36
|
+
"bibliography",
|
|
37
|
+
"resource_path",
|
|
38
|
+
}
|
|
39
|
+
_BOOL_PATCH_FIELDS = {
|
|
40
|
+
"standalone",
|
|
41
|
+
"toc",
|
|
42
|
+
"number_sections",
|
|
43
|
+
"embed_resources",
|
|
44
|
+
"citeproc",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class AddInputsResult:
|
|
50
|
+
added: tuple[str, ...]
|
|
51
|
+
duplicates: tuple[str, ...]
|
|
52
|
+
tool: ToolName
|
|
53
|
+
switched_tool: ToolName | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class TaskChange:
|
|
58
|
+
field: str
|
|
59
|
+
before: Any
|
|
60
|
+
after: Any
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class SessionState:
|
|
65
|
+
"""Owns the tasks and all filesystem-facing mutations.
|
|
66
|
+
|
|
67
|
+
Each tool keeps its own task, so switching tools preserves the other
|
|
68
|
+
tool's work. When the active task is still empty, adding files whose
|
|
69
|
+
extensions clearly belong to the other tool selects that tool
|
|
70
|
+
automatically.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
tool: ToolName = "ffmpeg"
|
|
74
|
+
ffmpeg_task: FFmpegTask = field(default_factory=FFmpegTask)
|
|
75
|
+
pandoc_task: PandocTask = field(default_factory=PandocTask)
|
|
76
|
+
revision: int = 0
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def task(self) -> FFmpegTask | PandocTask:
|
|
80
|
+
return self._task_for(self.tool)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def spec(self) -> ToolSpec:
|
|
84
|
+
return get_tool(self.tool)
|
|
85
|
+
|
|
86
|
+
def _task_for(self, tool: ToolName) -> FFmpegTask | PandocTask:
|
|
87
|
+
return self.ffmpeg_task if tool == "ffmpeg" else self.pandoc_task
|
|
88
|
+
|
|
89
|
+
def _store_task(self, tool: ToolName, task: FFmpegTask | PandocTask) -> None:
|
|
90
|
+
if tool == "ffmpeg":
|
|
91
|
+
self.ffmpeg_task = task # type: ignore[assignment]
|
|
92
|
+
else:
|
|
93
|
+
self.pandoc_task = task # type: ignore[assignment]
|
|
94
|
+
|
|
95
|
+
def select_tool(self, tool: ToolName) -> bool:
|
|
96
|
+
"""Switch the active tool. Returns whether the selection changed."""
|
|
97
|
+
|
|
98
|
+
if tool not in TOOL_SPECS:
|
|
99
|
+
raise StateError(f"Unknown conversion tool: {tool}")
|
|
100
|
+
if tool == self.tool:
|
|
101
|
+
return False
|
|
102
|
+
self.tool = tool
|
|
103
|
+
self.revision += 1
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def add_inputs(self, raw_paths: list[str] | tuple[str, ...]) -> AddInputsResult:
|
|
107
|
+
if not raw_paths:
|
|
108
|
+
raise StateError("add requires at least one input file")
|
|
109
|
+
|
|
110
|
+
candidates: list[Path] = []
|
|
111
|
+
seen: list[Path] = []
|
|
112
|
+
for raw in raw_paths:
|
|
113
|
+
if not raw.strip():
|
|
114
|
+
raise StateError("Input filenames must not be empty")
|
|
115
|
+
candidate = self._resolve_existing_file(raw)
|
|
116
|
+
if any(_same_file(candidate, existing) for existing in seen):
|
|
117
|
+
continue
|
|
118
|
+
seen.append(candidate)
|
|
119
|
+
candidates.append(candidate)
|
|
120
|
+
|
|
121
|
+
# Resolving every path above keeps the operation atomic: a missing
|
|
122
|
+
# file never leaves the session partially updated.
|
|
123
|
+
pending = [str(path) for path in candidates]
|
|
124
|
+
switched: ToolName | None = None
|
|
125
|
+
if not self.task.inputs and self.task.output is None:
|
|
126
|
+
detected = detect_tool(pending)
|
|
127
|
+
if detected is not None and detected != self.tool:
|
|
128
|
+
switched = detected
|
|
129
|
+
|
|
130
|
+
activation = switched or self.tool
|
|
131
|
+
task = self._task_for(activation)
|
|
132
|
+
|
|
133
|
+
added: list[str] = []
|
|
134
|
+
duplicates: list[str] = []
|
|
135
|
+
for candidate in candidates:
|
|
136
|
+
candidate_str = str(candidate)
|
|
137
|
+
if candidate_str in task.inputs or any(
|
|
138
|
+
_same_file(candidate, existing) for existing in task.inputs
|
|
139
|
+
):
|
|
140
|
+
duplicates.append(candidate_str)
|
|
141
|
+
continue
|
|
142
|
+
if task.output is not None and _same_file(candidate, task.output):
|
|
143
|
+
raise StateError(
|
|
144
|
+
f"Input file is also the current output: {candidate_str}. "
|
|
145
|
+
"Choose a different output with setoutput."
|
|
146
|
+
)
|
|
147
|
+
added.append(candidate_str)
|
|
148
|
+
|
|
149
|
+
if not added and switched is None:
|
|
150
|
+
return AddInputsResult(
|
|
151
|
+
added=(),
|
|
152
|
+
duplicates=tuple(duplicates),
|
|
153
|
+
tool=self.tool,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self.tool = activation
|
|
157
|
+
task.inputs.extend(added)
|
|
158
|
+
self.revision += 1
|
|
159
|
+
return AddInputsResult(
|
|
160
|
+
added=tuple(added),
|
|
161
|
+
duplicates=tuple(duplicates),
|
|
162
|
+
tool=self.tool,
|
|
163
|
+
switched_tool=switched,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def set_output(self, raw_path: str) -> str:
|
|
167
|
+
if not raw_path.strip():
|
|
168
|
+
raise StateError("Output filename must not be empty")
|
|
169
|
+
|
|
170
|
+
path = Path(raw_path).expanduser()
|
|
171
|
+
if not path.is_absolute():
|
|
172
|
+
path = Path.cwd() / path
|
|
173
|
+
if path.name in {"", ".", ".."}:
|
|
174
|
+
raise StateError("Output must be a filename, not a directory")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
parent = path.parent.resolve(strict=True)
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
raise StateError(f"Output directory does not exist: {path.parent}") from exc
|
|
180
|
+
if not parent.is_dir():
|
|
181
|
+
raise StateError(f"Output parent is not a directory: {parent}")
|
|
182
|
+
if not os.access(parent, os.W_OK | os.X_OK):
|
|
183
|
+
raise StateError(f"Output directory is not writable: {parent}")
|
|
184
|
+
|
|
185
|
+
target = parent / path.name
|
|
186
|
+
if target.exists() and target.is_dir():
|
|
187
|
+
raise StateError(f"Output path is a directory: {target}")
|
|
188
|
+
for source in self.task.inputs:
|
|
189
|
+
if _same_file(target, source):
|
|
190
|
+
raise StateError(
|
|
191
|
+
f"Output would overwrite an input file: {target}. Choose a different path."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
self.task.output = str(target)
|
|
195
|
+
self.revision += 1
|
|
196
|
+
return str(target)
|
|
197
|
+
|
|
198
|
+
def apply_patch(self, patch: ToolPatch) -> list[TaskChange]:
|
|
199
|
+
"""Apply only explicitly supplied patch fields and return a diff.
|
|
200
|
+
|
|
201
|
+
A patch with no tool is a no-op. Selecting a different tool is itself
|
|
202
|
+
a visible change even when the tool payload is empty.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
tool = patch.tool
|
|
206
|
+
fields = patch.fields
|
|
207
|
+
if tool is None or fields is None:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
target = self._task_for(tool)
|
|
211
|
+
updates = _patch_updates(fields)
|
|
212
|
+
if not updates and tool == self.tool:
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
candidate_data = target.model_dump()
|
|
216
|
+
candidate_data.update(updates)
|
|
217
|
+
try:
|
|
218
|
+
candidate = type(target).model_validate(candidate_data)
|
|
219
|
+
except ValidationError as exc:
|
|
220
|
+
raise StateError(f"The proposed task is invalid: {exc}") from exc
|
|
221
|
+
|
|
222
|
+
if isinstance(candidate, FFmpegTask) and candidate.filter_complex and (
|
|
223
|
+
candidate.video_filters or candidate.audio_filters
|
|
224
|
+
):
|
|
225
|
+
raise StateError(
|
|
226
|
+
"filter_complex cannot be combined with simple video_filters or "
|
|
227
|
+
"audio_filters. Clear the simple filters or remove filter_complex."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
before_data = target.model_dump()
|
|
231
|
+
candidate_snapshot = candidate.model_dump()
|
|
232
|
+
changes: list[TaskChange] = []
|
|
233
|
+
if tool != self.tool:
|
|
234
|
+
changes.append(TaskChange(field="tool", before=self.tool, after=tool))
|
|
235
|
+
changes.extend(
|
|
236
|
+
TaskChange(field=name, before=before_data[name], after=candidate_snapshot[name])
|
|
237
|
+
for name in updates
|
|
238
|
+
if before_data[name] != candidate_snapshot[name]
|
|
239
|
+
)
|
|
240
|
+
if not changes:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
self._store_task(tool, candidate)
|
|
244
|
+
self.tool = tool
|
|
245
|
+
self.revision += 1
|
|
246
|
+
return changes
|
|
247
|
+
|
|
248
|
+
def reset(self) -> None:
|
|
249
|
+
self._store_task(self.tool, get_tool(self.tool).task_type())
|
|
250
|
+
self.revision += 1
|
|
251
|
+
|
|
252
|
+
def llm_context(self) -> dict[str, Any]:
|
|
253
|
+
context = self.task.model_dump()
|
|
254
|
+
context["tool"] = self.tool
|
|
255
|
+
context["revision"] = self.revision
|
|
256
|
+
return context
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _resolve_existing_file(raw_path: str) -> Path:
|
|
260
|
+
try:
|
|
261
|
+
path = Path(raw_path).expanduser().resolve(strict=True)
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
raise StateError(f"Input file does not exist: {raw_path}") from exc
|
|
264
|
+
if not path.is_file():
|
|
265
|
+
raise StateError(f"Input is not a regular file: {path}")
|
|
266
|
+
if not os.access(path, os.R_OK):
|
|
267
|
+
raise StateError(f"Input file is not readable: {path}")
|
|
268
|
+
return path
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _patch_updates(fields: FFmpegPatch | PandocPatch) -> dict[str, Any]:
|
|
272
|
+
updates: dict[str, Any] = {}
|
|
273
|
+
for field_name in fields.model_fields_set:
|
|
274
|
+
value = getattr(fields, field_name)
|
|
275
|
+
if field_name in _LIST_PATCH_FIELDS and value is None:
|
|
276
|
+
value = []
|
|
277
|
+
if field_name in _BOOL_PATCH_FIELDS and value is None:
|
|
278
|
+
value = False
|
|
279
|
+
if field_name == "description" and value is None:
|
|
280
|
+
value = ""
|
|
281
|
+
updates[field_name] = value
|
|
282
|
+
return updates
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _same_file(left: str | Path, right: str | Path) -> bool:
|
|
286
|
+
try:
|
|
287
|
+
return os.path.samefile(left, right)
|
|
288
|
+
except OSError:
|
|
289
|
+
# At least one path does not exist yet; lexical comparison is the only
|
|
290
|
+
# available check and is sufficient for normalized SessionState paths.
|
|
291
|
+
return Path(left) == Path(right)
|
ffai/tools.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Registry of external conversion tools and extension-based routing.
|
|
2
|
+
|
|
3
|
+
Each supported tool exposes one task model, one patch model, and one argv
|
|
4
|
+
builder. The registry is the single place that maps a tool name to its
|
|
5
|
+
capabilities so the REPL, the prompt, and the session state stay tool-agnostic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ffai import command_builder, pandoc_builder
|
|
17
|
+
from ffai.models import (
|
|
18
|
+
FFMPEG_DISPLAY_FIELDS,
|
|
19
|
+
PANDOC_DISPLAY_FIELDS,
|
|
20
|
+
FFmpegPatch,
|
|
21
|
+
FFmpegTask,
|
|
22
|
+
PandocPatch,
|
|
23
|
+
PandocTask,
|
|
24
|
+
TaskBase,
|
|
25
|
+
ToolName,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Media container/codec extensions routed to FFmpeg.
|
|
29
|
+
MEDIA_EXTENSIONS: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
".3g2",
|
|
32
|
+
".3gp",
|
|
33
|
+
".aac",
|
|
34
|
+
".aif",
|
|
35
|
+
".aiff",
|
|
36
|
+
".alac",
|
|
37
|
+
".ape",
|
|
38
|
+
".avi",
|
|
39
|
+
".avif",
|
|
40
|
+
".bmp",
|
|
41
|
+
".flac",
|
|
42
|
+
".flv",
|
|
43
|
+
".gif",
|
|
44
|
+
".jpeg",
|
|
45
|
+
".jpg",
|
|
46
|
+
".m2ts",
|
|
47
|
+
".m4a",
|
|
48
|
+
".m4v",
|
|
49
|
+
".mkv",
|
|
50
|
+
".mov",
|
|
51
|
+
".mp3",
|
|
52
|
+
".mp4",
|
|
53
|
+
".mpeg",
|
|
54
|
+
".mpg",
|
|
55
|
+
".mts",
|
|
56
|
+
".oga",
|
|
57
|
+
".ogg",
|
|
58
|
+
".ogv",
|
|
59
|
+
".opus",
|
|
60
|
+
".png",
|
|
61
|
+
".tif",
|
|
62
|
+
".tiff",
|
|
63
|
+
".ts",
|
|
64
|
+
".vob",
|
|
65
|
+
".wav",
|
|
66
|
+
".webm",
|
|
67
|
+
".webp",
|
|
68
|
+
".wma",
|
|
69
|
+
".wmv",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Document extensions routed to Pandoc.
|
|
74
|
+
DOCUMENT_EXTENSIONS: frozenset[str] = frozenset(
|
|
75
|
+
{
|
|
76
|
+
".adoc",
|
|
77
|
+
".asciidoc",
|
|
78
|
+
".docx",
|
|
79
|
+
".epub",
|
|
80
|
+
".htm",
|
|
81
|
+
".html",
|
|
82
|
+
".ipynb",
|
|
83
|
+
".latex",
|
|
84
|
+
".markdown",
|
|
85
|
+
".md",
|
|
86
|
+
".mediawiki",
|
|
87
|
+
".odt",
|
|
88
|
+
".opml",
|
|
89
|
+
".org",
|
|
90
|
+
".pptx",
|
|
91
|
+
".rst",
|
|
92
|
+
".rtf",
|
|
93
|
+
".tex",
|
|
94
|
+
".text",
|
|
95
|
+
".textile",
|
|
96
|
+
".txt",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class ToolSpec:
|
|
103
|
+
"""Everything the application needs to know about one external tool."""
|
|
104
|
+
|
|
105
|
+
name: ToolName
|
|
106
|
+
binary: str
|
|
107
|
+
label: str
|
|
108
|
+
task_type: type[TaskBase]
|
|
109
|
+
patch_type: type[FFmpegPatch] | type[PandocPatch]
|
|
110
|
+
display_fields: tuple[str, ...]
|
|
111
|
+
default_description: str
|
|
112
|
+
supports_progress: bool
|
|
113
|
+
builder: Callable[[Any], list[str]]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
TOOL_SPECS: dict[ToolName, ToolSpec] = {
|
|
117
|
+
"ffmpeg": ToolSpec(
|
|
118
|
+
name="ffmpeg",
|
|
119
|
+
binary="ffmpeg",
|
|
120
|
+
label="FFmpeg",
|
|
121
|
+
task_type=FFmpegTask,
|
|
122
|
+
patch_type=FFmpegPatch,
|
|
123
|
+
display_fields=FFMPEG_DISPLAY_FIELDS,
|
|
124
|
+
default_description="New FFmpeg task",
|
|
125
|
+
supports_progress=True,
|
|
126
|
+
builder=command_builder.build_command,
|
|
127
|
+
),
|
|
128
|
+
"pandoc": ToolSpec(
|
|
129
|
+
name="pandoc",
|
|
130
|
+
binary="pandoc",
|
|
131
|
+
label="Pandoc",
|
|
132
|
+
task_type=PandocTask,
|
|
133
|
+
patch_type=PandocPatch,
|
|
134
|
+
display_fields=PANDOC_DISPLAY_FIELDS,
|
|
135
|
+
default_description="New pandoc task",
|
|
136
|
+
supports_progress=False,
|
|
137
|
+
builder=pandoc_builder.build_command,
|
|
138
|
+
),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
TOOL_NAMES: tuple[ToolName, ...] = tuple(TOOL_SPECS)
|
|
142
|
+
|
|
143
|
+
_EXTENSION_TOOL: dict[str, ToolName] = {
|
|
144
|
+
**{extension: "ffmpeg" for extension in MEDIA_EXTENSIONS},
|
|
145
|
+
**{extension: "pandoc" for extension in DOCUMENT_EXTENSIONS},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_tool(name: ToolName) -> ToolSpec:
|
|
150
|
+
return TOOL_SPECS[name]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def detect_tool(paths: Sequence[str]) -> ToolName | None:
|
|
154
|
+
"""Infer the conversion tool from the extension of *paths*.
|
|
155
|
+
|
|
156
|
+
Returns ``None`` when the extensions are unknown or point at different
|
|
157
|
+
tools, in which case the caller keeps the currently selected tool.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
detected = {
|
|
161
|
+
_EXTENSION_TOOL[Path(path).suffix.lower()]
|
|
162
|
+
for path in paths
|
|
163
|
+
if Path(path).suffix.lower() in _EXTENSION_TOOL
|
|
164
|
+
}
|
|
165
|
+
return detected.pop() if len(detected) == 1 else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tool_available(name: ToolName) -> bool:
|
|
169
|
+
return shutil.which(TOOL_SPECS[name].binary) is not None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def available_tools() -> tuple[ToolName, ...]:
|
|
173
|
+
return tuple(name for name in TOOL_NAMES if tool_available(name))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def missing_tools() -> tuple[ToolName, ...]:
|
|
177
|
+
return tuple(name for name in TOOL_NAMES if not tool_available(name))
|
ffai/utils.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Small, side-effect-light helpers shared by the CLI and executor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_SECRET_ENV_NAME = re.compile(r"KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_command(args: Sequence[str]) -> str:
|
|
17
|
+
"""Render argv as a copy-pasteable shell command for display only."""
|
|
18
|
+
|
|
19
|
+
return shlex.join(str(arg) for arg in args)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scrubbed_environment(source: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
23
|
+
"""Return an environment without credentials for spawned media tools."""
|
|
24
|
+
|
|
25
|
+
environment = os.environ if source is None else source
|
|
26
|
+
return {name: value for name, value in environment.items() if not _SECRET_ENV_NAME.search(name)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def binary_available(binary: str) -> bool:
|
|
30
|
+
"""Return whether *binary* can be found on PATH."""
|
|
31
|
+
|
|
32
|
+
return shutil.which(binary) is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def probe_duration(path: str, timeout_seconds: float = 10.0) -> float | None:
|
|
36
|
+
"""Best-effort media duration used only to render a progress bar.
|
|
37
|
+
|
|
38
|
+
The FFmpeg run itself is still reported from its real exit status. Missing
|
|
39
|
+
ffprobe, malformed media, and timeouts therefore degrade to an unknown
|
|
40
|
+
duration rather than masking or changing command execution.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if shutil.which("ffprobe") is None:
|
|
44
|
+
return None
|
|
45
|
+
command = [
|
|
46
|
+
"ffprobe",
|
|
47
|
+
"-v",
|
|
48
|
+
"error",
|
|
49
|
+
"-show_entries",
|
|
50
|
+
"format=duration",
|
|
51
|
+
"-of",
|
|
52
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
53
|
+
str(Path(path)),
|
|
54
|
+
]
|
|
55
|
+
try:
|
|
56
|
+
completed = subprocess.run(
|
|
57
|
+
command,
|
|
58
|
+
stdin=subprocess.DEVNULL,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
timeout=timeout_seconds,
|
|
62
|
+
check=False,
|
|
63
|
+
env=scrubbed_environment(),
|
|
64
|
+
)
|
|
65
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
66
|
+
return None
|
|
67
|
+
if completed.returncode != 0:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
duration = float(completed.stdout.strip().splitlines()[0])
|
|
71
|
+
except (IndexError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
return duration if duration > 0 else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_file_size(path: str) -> str | None:
|
|
77
|
+
try:
|
|
78
|
+
size = Path(path).stat().st_size
|
|
79
|
+
except OSError:
|
|
80
|
+
return None
|
|
81
|
+
units = ("B", "KiB", "MiB", "GiB", "TiB")
|
|
82
|
+
value = float(size)
|
|
83
|
+
for unit in units:
|
|
84
|
+
if value < 1024 or unit == units[-1]:
|
|
85
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
86
|
+
value /= 1024
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ffmpeg_available() -> bool:
|
|
91
|
+
return binary_available("ffmpeg")
|