pyetool 0.1.0__tar.gz
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.
- pyetool-0.1.0/.claude/settings.local.json +8 -0
- pyetool-0.1.0/.envrc +1 -0
- pyetool-0.1.0/.gitignore +3 -0
- pyetool-0.1.0/.python-version +1 -0
- pyetool-0.1.0/CLAUDE.md +24 -0
- pyetool-0.1.0/PKG-INFO +28 -0
- pyetool-0.1.0/README.md +15 -0
- pyetool-0.1.0/main.py +6 -0
- pyetool-0.1.0/pyetool/__init__.py +0 -0
- pyetool-0.1.0/pyetool/tool/__init__.py +0 -0
- pyetool-0.1.0/pyetool/tool/ffmpeg/__init__.py +28 -0
- pyetool-0.1.0/pyetool/tool/ffmpeg/ffmpeg.py +228 -0
- pyetool-0.1.0/pyetool/tool/ffmpeg/ffprobe.py +139 -0
- pyetool-0.1.0/pyetool/tool/ffmpeg/model.py +88 -0
- pyetool-0.1.0/pyetool/tool/ffmpeg/typing.py +24 -0
- pyetool-0.1.0/pyetool/tool/ffmpeg/util.py +16 -0
- pyetool-0.1.0/pyetool/tool/obj_storage/__init__.py +12 -0
- pyetool-0.1.0/pyetool/tool/obj_storage/backend.py +17 -0
- pyetool-0.1.0/pyetool/tool/obj_storage/cli.py +77 -0
- pyetool-0.1.0/pyetool/tool/obj_storage/factory.py +35 -0
- pyetool-0.1.0/pyetool/tool/obj_storage/volc_tos.py +70 -0
- pyetool-0.1.0/pyetool/tool/web_api/__init__.py +0 -0
- pyetool-0.1.0/pyetool/tool/web_api/pexels.py +16 -0
- pyetool-0.1.0/pyetool/util/__init__.py +0 -0
- pyetool-0.1.0/pyetool/util/env.py +8 -0
- pyetool-0.1.0/pyetool/util/monad.py +25 -0
- pyetool-0.1.0/pyetool/util/path.py +13 -0
- pyetool-0.1.0/pyproject.toml +65 -0
- pyetool-0.1.0/tests/test_ffmpeg/conftest.py +19 -0
- pyetool-0.1.0/tests/test_ffmpeg/data/.gitignore +5 -0
- pyetool-0.1.0/tests/test_ffmpeg/data/generate.py +55 -0
- pyetool-0.1.0/tests/test_ffmpeg/data/tiny.mp4 +0 -0
- pyetool-0.1.0/tests/test_ffmpeg/test_ffmpeg.py +87 -0
- pyetool-0.1.0/tests/test_ffmpeg/test_ffprobe.py +47 -0
- pyetool-0.1.0/tests/test_obj_storage/test_obj_storage.py +105 -0
- pyetool-0.1.0/tests/test_util/test_path.py +10 -0
- pyetool-0.1.0/uv.lock +741 -0
pyetool-0.1.0/.envrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
UV_CACHE_DIR="$TMPDIR/.uv-cache"
|
pyetool-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
pyetool-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 项目目标
|
|
2
|
+
这个项目是为了提升个人生产力而制作的一系列 Python 工具。
|
|
3
|
+
|
|
4
|
+
# 行为规范
|
|
5
|
+
- `用中文与我对话`,`代码和注释使用英文,包括docstring`,`文档允许中英混合`。
|
|
6
|
+
- AI是可替换的助力,不要在任何地方体现claude的存在。
|
|
7
|
+
- 设计决策总是等我确认后才执行。
|
|
8
|
+
|
|
9
|
+
# 设计理念
|
|
10
|
+
- 所有可利用的“资源”都应该对象化,比如某个网页(like, playwright page),web API,shell command(like ffmpeg), etc。如此一来,所有东西都可以用 Python 粘在一起。
|
|
11
|
+
- Runnable python module: 功能足够独立使用的module都应该有自己的cli。cli都是可以用pipeline组合的。
|
|
12
|
+
|
|
13
|
+
# 偏好
|
|
14
|
+
- 偏好functional programming做局部实现。
|
|
15
|
+
- 比起module+纯函数,偏好用class做更灵活的显式封装。
|
|
16
|
+
- 只对功能本身进行测试,不对其中的算法过程进行测试。测试时,如无必要无需mock,尽量使用真实测试数据进行测试(如果你无法准备此数据,应通知我)。冒烟测试可以做one-time check。trivial的测试越少越好。
|
|
17
|
+
- Correctness > Readability(IMPORTANT) > Algorithm Efficiency。代码应该着重强化可读性.
|
|
18
|
+
- naming应该考究,上下文中已有的信息无需反复出现。
|
|
19
|
+
- 不同逻辑的代码块之间应该以空行分隔。
|
|
20
|
+
- 尽可能标注typing.
|
|
21
|
+
- 外部规则、人为协定、无法从代码中推断的东西、有意为之的trick,应该加注释。复杂算法需要加注释。其他情况下,注释尽量少。
|
|
22
|
+
|
|
23
|
+
# 规范
|
|
24
|
+
- 用pydantic model写data model.
|
pyetool-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyetool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: click>=8.3.3
|
|
7
|
+
Requires-Dist: google-genai>=2.0.1
|
|
8
|
+
Requires-Dist: pydantic>=2.13.4
|
|
9
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
10
|
+
Requires-Dist: rich>=15.0.0
|
|
11
|
+
Requires-Dist: tos>=2.9.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Pyetool
|
|
15
|
+
|
|
16
|
+
A personal efficiency enhancing tool
|
|
17
|
+
|
|
18
|
+
## sprint 1
|
|
19
|
+
- pexel api
|
|
20
|
+
- video downloader
|
|
21
|
+
- video resource object, understand method
|
|
22
|
+
- audio resource object, transcribe method
|
|
23
|
+
- bilibili page object, data & action
|
|
24
|
+
|
|
25
|
+
## sprint 2
|
|
26
|
+
- GIF resource object, understand method
|
|
27
|
+
- music resource object, understand method
|
|
28
|
+
- resource management
|
pyetool-0.1.0/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Pyetool
|
|
2
|
+
|
|
3
|
+
A personal efficiency enhancing tool
|
|
4
|
+
|
|
5
|
+
## sprint 1
|
|
6
|
+
- pexel api
|
|
7
|
+
- video downloader
|
|
8
|
+
- video resource object, understand method
|
|
9
|
+
- audio resource object, transcribe method
|
|
10
|
+
- bilibili page object, data & action
|
|
11
|
+
|
|
12
|
+
## sprint 2
|
|
13
|
+
- GIF resource object, understand method
|
|
14
|
+
- music resource object, understand method
|
|
15
|
+
- resource management
|
pyetool-0.1.0/main.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Object-oriented wrappers around ffmpeg and ffprobe."""
|
|
2
|
+
|
|
3
|
+
from .ffmpeg import FFmpeg
|
|
4
|
+
from .ffprobe import FFprobe
|
|
5
|
+
from .model import (
|
|
6
|
+
ChapterInfo,
|
|
7
|
+
FormatInfo,
|
|
8
|
+
ProbeResult,
|
|
9
|
+
ProgramInfo,
|
|
10
|
+
StreamInfo,
|
|
11
|
+
)
|
|
12
|
+
from .typing import AudioCodec, LogLevel, Preset, VideoCodec
|
|
13
|
+
from .util import hms
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AudioCodec",
|
|
17
|
+
"ChapterInfo",
|
|
18
|
+
"FFmpeg",
|
|
19
|
+
"FFprobe",
|
|
20
|
+
"FormatInfo",
|
|
21
|
+
"LogLevel",
|
|
22
|
+
"Preset",
|
|
23
|
+
"ProbeResult",
|
|
24
|
+
"ProgramInfo",
|
|
25
|
+
"StreamInfo",
|
|
26
|
+
"VideoCodec",
|
|
27
|
+
"hms",
|
|
28
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""FFmpeg command builder and runner."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pyetool.util.monad import Err, Ok, Result
|
|
9
|
+
|
|
10
|
+
from .typing import AudioCodec, Preset, VideoCodec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FFmpeg:
|
|
14
|
+
"""FFmpeg command builder and runner."""
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
if shutil.which("ffmpeg") is None:
|
|
17
|
+
raise RuntimeError("FFmpeg is not installed or not found in PATH.")
|
|
18
|
+
self._init_state()
|
|
19
|
+
|
|
20
|
+
def _init_state(self) -> None:
|
|
21
|
+
self._input_file: str = ''
|
|
22
|
+
self._start_time: float | None = None
|
|
23
|
+
self._duration: float | None = None
|
|
24
|
+
self._end_time: float | None = None
|
|
25
|
+
self._video_filters: list[str] = []
|
|
26
|
+
self._strip_audio: bool = False
|
|
27
|
+
self._strip_video: bool = False
|
|
28
|
+
self._codec_name: VideoCodec | None = None
|
|
29
|
+
self._video_bitrate: str | None = None
|
|
30
|
+
self._frame_rate: float | None = None
|
|
31
|
+
self._crf: int | None = None
|
|
32
|
+
self._preset: Preset | None = None
|
|
33
|
+
self._acodec_name: AudioCodec | None = None
|
|
34
|
+
self._audio_bitrate: str | None = None
|
|
35
|
+
self._audio_sampling_rate: int | None = None
|
|
36
|
+
self._n_audio_channel: int | None = None
|
|
37
|
+
self._format: str | None = None
|
|
38
|
+
self._maps: list[str] = []
|
|
39
|
+
self._metadata: list[tuple[str, str]] = []
|
|
40
|
+
self._overwrite: bool = True
|
|
41
|
+
self._output_file: str = ''
|
|
42
|
+
|
|
43
|
+
def input_file(self, f: str | Path) -> Self:
|
|
44
|
+
"""Set the source file to process (maps to `-i`)."""
|
|
45
|
+
self._input_file = str(f)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def start_time(self, seconds: float) -> Self:
|
|
49
|
+
"""Where to start reading from the source, in seconds (maps to `-ss`, placed before `-i` for fast seek). For `"HH:MM:SS"` input, convert via `hms()` first."""
|
|
50
|
+
self._start_time = seconds
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def duration(self, seconds: float) -> Self:
|
|
54
|
+
"""Maximum output duration in seconds (maps to `-t`); stops when reached. Pair with `start_time` to cut a clip."""
|
|
55
|
+
self._duration = seconds
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def end_time(self, seconds: float) -> Self:
|
|
59
|
+
"""Stop reading at this timestamp of the source, in seconds (maps to `-to`). Use this OR `duration`, not both."""
|
|
60
|
+
self._end_time = seconds
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def add_video_filter(self, video_filter: str) -> Self:
|
|
64
|
+
"""Append a video filter such as `scale=1280:720`, crop, or watermark (maps to `-vf`). Multiple filters are joined with commas and applied in order."""
|
|
65
|
+
self._video_filters.append(video_filter)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def add_video_filters(self, video_filters: list[str]) -> Self:
|
|
69
|
+
"""Bulk append video filters; same semantics as `add_video_filter`."""
|
|
70
|
+
self._video_filters.extend(video_filters)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def get_video_filters(self) -> list[str]:
|
|
74
|
+
"""Return the current list of video filters."""
|
|
75
|
+
return self._video_filters
|
|
76
|
+
|
|
77
|
+
def clear_video_filters(self) -> Self:
|
|
78
|
+
"""Clear all video filters. Useful when reusing the builder and the previous filters should not carry over."""
|
|
79
|
+
self._video_filters.clear()
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def strip_audio(self, yes: bool = True) -> Self:
|
|
83
|
+
"""Drop the audio track, keep video only (maps to `-an`). Pass `yes=False` to undo for state reuse."""
|
|
84
|
+
self._strip_audio = yes
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def strip_video(self, yes: bool = True) -> Self:
|
|
88
|
+
"""Drop the video track, keep audio only (maps to `-vn`); common for extracting audio from a video. Pass `yes=False` to undo."""
|
|
89
|
+
self._strip_video = yes
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def codec(self, name: VideoCodec) -> Self:
|
|
93
|
+
"""Set the video encoder (maps to `-c:v`, equivalent to the older `-vcodec`). `copy` muxes the source stream untouched — the target container must accept that codec or ffmpeg will error."""
|
|
94
|
+
self._codec_name = name
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def video_bitrate(self, b: str) -> Self:
|
|
98
|
+
"""Video bitrate (maps to `-b:v`), e.g. `"2M"`, `"800k"`."""
|
|
99
|
+
self._video_bitrate = b
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def frame_rate(self, r: float) -> Self:
|
|
103
|
+
"""Output frame rate in fps (maps to `-r`), e.g. 24, 30, 60."""
|
|
104
|
+
self._frame_rate = r
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def crf(self, value: int) -> Self:
|
|
108
|
+
"""x264/x265 quality knob (maps to `-crf`), range 0–51. Lower is sharper but larger; 18–28 is the typical band."""
|
|
109
|
+
self._crf = value
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def preset(self, p: Preset) -> Self:
|
|
113
|
+
"""x264/x265 speed-vs-compression trade-off (maps to `-preset`). Faster preset → larger file; slower preset → better compression."""
|
|
114
|
+
self._preset = p
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def acodec(self, name: AudioCodec) -> Self:
|
|
118
|
+
"""Set the audio encoder (maps to `-c:a`, equivalent to the older `-acodec`). `copy` muxes the source stream untouched — the target container must accept that codec."""
|
|
119
|
+
self._acodec_name = name
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def audio_bitrate(self, b: str) -> Self:
|
|
123
|
+
"""Audio bitrate (maps to `-b:a`), e.g. `"192k"`, `"128k"`."""
|
|
124
|
+
self._audio_bitrate = b
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def audio_sampling_rate(self, hz: int) -> Self:
|
|
128
|
+
"""Audio sampling rate in Hz (maps to `-ar`). Common values: 44100 (CD), 48000 (video), 16000 (speech)."""
|
|
129
|
+
self._audio_sampling_rate = hz
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def n_audio_channel(self, n_channel: int) -> Self:
|
|
133
|
+
"""Number of audio channels (maps to `-ac`): 1=mono, 2=stereo, 6=5.1."""
|
|
134
|
+
self._n_audio_channel = n_channel
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def format(self, fmt: str) -> Self:
|
|
138
|
+
"""Force the output container format (maps to `-f`), e.g. `"mp4"`, `"matroska"`. Useful when the output has no extension or to override the inferred default."""
|
|
139
|
+
self._format = fmt
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def add_map(self, stream: str) -> Self:
|
|
143
|
+
"""Pick which streams to keep (maps to `-map`, can be called multiple times). E.g. `"0:v:0"` = first video stream of input 0, `"0:a"` = all audio streams of input 0. Needed for multi-track or subtitle scenarios."""
|
|
144
|
+
self._maps.append(stream)
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def add_metadata(self, key: str, value: str) -> Self:
|
|
148
|
+
"""Write a metadata entry (maps to `-metadata key=value`, can be called multiple times), e.g. title, artist, comment."""
|
|
149
|
+
self._metadata.append((key, value))
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def overwrite(self, yes: bool = True) -> Self:
|
|
153
|
+
"""Whether to overwrite the output file if it exists. `True` → `-y` (default), `False` → `-n` (abort if exists)."""
|
|
154
|
+
self._overwrite = yes
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def output_file(self, f: str | Path) -> Self:
|
|
158
|
+
"""Set the output file path. Defaults to overwriting an existing file; call `overwrite(False)` to disable."""
|
|
159
|
+
self._output_file = str(f)
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def reset(self) -> Self:
|
|
163
|
+
"""Clear every configured option, returning the builder to its just-constructed state."""
|
|
164
|
+
self._init_state()
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def run(self) -> Result[Path]:
|
|
168
|
+
"""Invoke ffmpeg. On success returns `Ok(Path(output_file))`; on non-zero exit returns `Err(returncode, stderr)`."""
|
|
169
|
+
if not self._input_file:
|
|
170
|
+
raise RuntimeError("input file missing")
|
|
171
|
+
if not self._output_file:
|
|
172
|
+
raise RuntimeError("output file not specified")
|
|
173
|
+
|
|
174
|
+
args: list[str] = []
|
|
175
|
+
|
|
176
|
+
if self._start_time is not None:
|
|
177
|
+
args.extend(["-ss", str(self._start_time)])
|
|
178
|
+
|
|
179
|
+
args.extend(["-i", self._input_file])
|
|
180
|
+
|
|
181
|
+
if self._duration is not None:
|
|
182
|
+
args.extend(["-t", str(self._duration)])
|
|
183
|
+
if self._end_time is not None:
|
|
184
|
+
args.extend(["-to", str(self._end_time)])
|
|
185
|
+
|
|
186
|
+
if self._video_filters:
|
|
187
|
+
args.extend(["-vf", ",".join(self._video_filters)])
|
|
188
|
+
|
|
189
|
+
if self._strip_audio:
|
|
190
|
+
args.append("-an")
|
|
191
|
+
if self._strip_video:
|
|
192
|
+
args.append("-vn")
|
|
193
|
+
|
|
194
|
+
if self._codec_name is not None:
|
|
195
|
+
args.extend(["-c:v", self._codec_name])
|
|
196
|
+
if self._video_bitrate is not None:
|
|
197
|
+
args.extend(["-b:v", self._video_bitrate])
|
|
198
|
+
if self._frame_rate is not None:
|
|
199
|
+
args.extend(["-r", str(self._frame_rate)])
|
|
200
|
+
if self._crf is not None:
|
|
201
|
+
args.extend(["-crf", str(self._crf)])
|
|
202
|
+
if self._preset is not None:
|
|
203
|
+
args.extend(["-preset", self._preset])
|
|
204
|
+
|
|
205
|
+
if self._acodec_name is not None:
|
|
206
|
+
args.extend(["-c:a", self._acodec_name])
|
|
207
|
+
if self._audio_bitrate is not None:
|
|
208
|
+
args.extend(["-b:a", self._audio_bitrate])
|
|
209
|
+
if self._audio_sampling_rate is not None:
|
|
210
|
+
args.extend(["-ar", str(self._audio_sampling_rate)])
|
|
211
|
+
if self._n_audio_channel is not None:
|
|
212
|
+
args.extend(["-ac", str(self._n_audio_channel)])
|
|
213
|
+
|
|
214
|
+
for stream in self._maps:
|
|
215
|
+
args.extend(["-map", stream])
|
|
216
|
+
for key, value in self._metadata:
|
|
217
|
+
args.extend(["-metadata", f"{key}={value}"])
|
|
218
|
+
|
|
219
|
+
if self._format is not None:
|
|
220
|
+
args.extend(["-f", self._format])
|
|
221
|
+
|
|
222
|
+
args.append("-y" if self._overwrite else "-n")
|
|
223
|
+
args.append(self._output_file)
|
|
224
|
+
|
|
225
|
+
completed = subprocess.run(["ffmpeg", *args], capture_output=True, text=True, check=False)
|
|
226
|
+
if completed.returncode != 0:
|
|
227
|
+
return Err(returncode=completed.returncode, err=completed.stderr)
|
|
228
|
+
return Ok(Path(self._output_file))
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""FFprobe command builder and runner for inspecting media files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
from pyetool.util.monad import Err, Ok, Result
|
|
10
|
+
|
|
11
|
+
from .model import ProbeResult
|
|
12
|
+
from .typing import LogLevel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FFprobe:
|
|
16
|
+
"""FFprobe command builder and runner for inspecting media files."""
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
if shutil.which("ffprobe") is None:
|
|
19
|
+
raise RuntimeError("FFprobe is not installed or not found in PATH.")
|
|
20
|
+
self._init_state()
|
|
21
|
+
|
|
22
|
+
def _init_state(self) -> None:
|
|
23
|
+
self._input_file: str = ''
|
|
24
|
+
self._loglevel: LogLevel | None = None
|
|
25
|
+
self._include_format: bool = False
|
|
26
|
+
self._include_streams: bool = False
|
|
27
|
+
self._include_packets: bool = False
|
|
28
|
+
self._include_frames: bool = False
|
|
29
|
+
self._include_chapters: bool = False
|
|
30
|
+
self._include_programs: bool = False
|
|
31
|
+
self._include_entries: str | None = None
|
|
32
|
+
self._select_streams: str | None = None
|
|
33
|
+
self._count_frames: bool = False
|
|
34
|
+
self._count_packets: bool = False
|
|
35
|
+
|
|
36
|
+
def input_file(self, f: str | Path) -> Self:
|
|
37
|
+
"""Set the media file to inspect (maps to `-i`)."""
|
|
38
|
+
self._input_file = str(f)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def loglevel(self, level: LogLevel) -> Self:
|
|
42
|
+
"""Set logging verbosity (maps to `-v`). Use `"error"` to suppress banners and only see real errors."""
|
|
43
|
+
self._loglevel = level
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def include_format(self, yes: bool = True) -> Self:
|
|
47
|
+
"""Include container-level info (duration, bitrate, format name, tags) in the output (maps to `-show_format`)."""
|
|
48
|
+
self._include_format = yes
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def include_streams(self, yes: bool = True) -> Self:
|
|
52
|
+
"""Include per-stream info (codec, resolution, channels, sample rate, etc.) in the output (maps to `-show_streams`)."""
|
|
53
|
+
self._include_streams = yes
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def include_packets(self, yes: bool = True) -> Self:
|
|
57
|
+
"""Include packet-level info (maps to `-show_packets`). Verbose; only useful for low-level debugging."""
|
|
58
|
+
self._include_packets = yes
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def include_frames(self, yes: bool = True) -> Self:
|
|
62
|
+
"""Include frame-level info (maps to `-show_frames`). Very verbose; slow on long files."""
|
|
63
|
+
self._include_frames = yes
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def include_chapters(self, yes: bool = True) -> Self:
|
|
67
|
+
"""Include chapter info (maps to `-show_chapters`)."""
|
|
68
|
+
self._include_chapters = yes
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def include_programs(self, yes: bool = True) -> Self:
|
|
72
|
+
"""Include program info (maps to `-show_programs`); relevant for MPEG-TS and similar containers."""
|
|
73
|
+
self._include_programs = yes
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def include_entries(self, spec: str) -> Self:
|
|
77
|
+
"""Restrict the output to specific entries (maps to `-show_entries`), e.g. `"stream=codec_name,width,height"` or `"format=duration"`."""
|
|
78
|
+
self._include_entries = spec
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def select_streams(self, spec: str) -> Self:
|
|
82
|
+
"""Limit which streams are reported (maps to `-select_streams`), e.g. `"v"` = all video, `"a:0"` = first audio, `"s"` = subtitles."""
|
|
83
|
+
self._select_streams = spec
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def count_frames(self, yes: bool = True) -> Self:
|
|
87
|
+
"""Count frames per stream (maps to `-count_frames`). Requires decoding, so it is slow but exact."""
|
|
88
|
+
self._count_frames = yes
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def count_packets(self, yes: bool = True) -> Self:
|
|
92
|
+
"""Count packets per stream (maps to `-count_packets`). Cheaper than `count_frames`."""
|
|
93
|
+
self._count_packets = yes
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def reset(self) -> Self:
|
|
97
|
+
"""Clear every configured option, returning the builder to its just-constructed state."""
|
|
98
|
+
self._init_state()
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def run(self) -> Result[ProbeResult]:
|
|
102
|
+
"""Invoke ffprobe with JSON output forced internally. On success returns `Ok(ProbeResult)`; on non-zero exit returns `Err`."""
|
|
103
|
+
if not self._input_file:
|
|
104
|
+
raise RuntimeError("input file missing")
|
|
105
|
+
|
|
106
|
+
args: list[str] = ["-of", "json"]
|
|
107
|
+
|
|
108
|
+
if self._loglevel is not None:
|
|
109
|
+
args.extend(["-v", self._loglevel])
|
|
110
|
+
if self._select_streams is not None:
|
|
111
|
+
args.extend(["-select_streams", self._select_streams])
|
|
112
|
+
if self._include_entries is not None:
|
|
113
|
+
args.extend(["-show_entries", self._include_entries])
|
|
114
|
+
|
|
115
|
+
if self._include_format:
|
|
116
|
+
args.append("-show_format")
|
|
117
|
+
if self._include_streams:
|
|
118
|
+
args.append("-show_streams")
|
|
119
|
+
if self._include_packets:
|
|
120
|
+
args.append("-show_packets")
|
|
121
|
+
if self._include_frames:
|
|
122
|
+
args.append("-show_frames")
|
|
123
|
+
if self._include_chapters:
|
|
124
|
+
args.append("-show_chapters")
|
|
125
|
+
if self._include_programs:
|
|
126
|
+
args.append("-show_programs")
|
|
127
|
+
|
|
128
|
+
if self._count_frames:
|
|
129
|
+
args.append("-count_frames")
|
|
130
|
+
if self._count_packets:
|
|
131
|
+
args.append("-count_packets")
|
|
132
|
+
|
|
133
|
+
args.extend(["-i", self._input_file])
|
|
134
|
+
|
|
135
|
+
completed = subprocess.run(["ffprobe", *args], capture_output=True, text=True, check=False)
|
|
136
|
+
if completed.returncode != 0:
|
|
137
|
+
return Err(returncode=completed.returncode, err=completed.stderr)
|
|
138
|
+
|
|
139
|
+
return Ok(ProbeResult.model_validate(json.loads(completed.stdout)))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Pydantic models for parsed ffprobe JSON output.
|
|
2
|
+
|
|
3
|
+
Each model uses `extra="allow"`, so any unknown field returned by a future ffprobe version is
|
|
4
|
+
preserved on `model_extra` and accessible without changing this file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _ProbeModel(BaseModel):
|
|
13
|
+
"""Base for ffprobe section models. Unknown fields are preserved on `model_extra` as the raw-escape hatch."""
|
|
14
|
+
model_config = ConfigDict(extra="allow")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FormatInfo(_ProbeModel):
|
|
18
|
+
"""ffprobe `format` section — container-level info."""
|
|
19
|
+
filename: str = ""
|
|
20
|
+
format_name: str = ""
|
|
21
|
+
format_long_name: str | None = None
|
|
22
|
+
duration: float | None = None
|
|
23
|
+
size: int | None = None
|
|
24
|
+
bit_rate: int | None = None
|
|
25
|
+
nb_streams: int = 0
|
|
26
|
+
nb_programs: int = 0
|
|
27
|
+
start_time: float | None = None
|
|
28
|
+
probe_score: int | None = None
|
|
29
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StreamInfo(_ProbeModel):
|
|
33
|
+
"""ffprobe `streams[i]` element — per-stream info. Video-only and audio-only fields stay `None` for streams of the other type."""
|
|
34
|
+
index: int = 0
|
|
35
|
+
codec_name: str = ""
|
|
36
|
+
codec_long_name: str | None = None
|
|
37
|
+
codec_type: Literal["video", "audio", "subtitle", "data", "attachment"] = "data"
|
|
38
|
+
profile: str | None = None
|
|
39
|
+
duration: float | None = None
|
|
40
|
+
bit_rate: int | None = None
|
|
41
|
+
nb_frames: int | None = None
|
|
42
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
# video-only
|
|
45
|
+
width: int | None = None
|
|
46
|
+
height: int | None = None
|
|
47
|
+
pix_fmt: str | None = None
|
|
48
|
+
r_frame_rate: str | None = None
|
|
49
|
+
avg_frame_rate: str | None = None
|
|
50
|
+
display_aspect_ratio: str | None = None
|
|
51
|
+
|
|
52
|
+
# audio-only
|
|
53
|
+
sample_rate: int | None = None
|
|
54
|
+
channels: int | None = None
|
|
55
|
+
channel_layout: str | None = None
|
|
56
|
+
sample_fmt: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ChapterInfo(_ProbeModel):
|
|
60
|
+
"""ffprobe `chapters[i]` element."""
|
|
61
|
+
id: int = 0
|
|
62
|
+
time_base: str = ""
|
|
63
|
+
start: int = 0
|
|
64
|
+
start_time: float = 0.0
|
|
65
|
+
end: int = 0
|
|
66
|
+
end_time: float = 0.0
|
|
67
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ProgramInfo(_ProbeModel):
|
|
71
|
+
"""ffprobe `programs[i]` element. Mostly relevant for MPEG-TS and similar multi-program containers."""
|
|
72
|
+
program_id: int = 0
|
|
73
|
+
program_num: int = 0
|
|
74
|
+
nb_streams: int = 0
|
|
75
|
+
pmt_pid: int | None = None
|
|
76
|
+
pcr_pid: int | None = None
|
|
77
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
78
|
+
streams: list[StreamInfo] = Field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProbeResult(_ProbeModel):
|
|
82
|
+
"""Parsed ffprobe JSON output. Each section is populated only when the corresponding `include_*` flag was set."""
|
|
83
|
+
format: FormatInfo | None = None
|
|
84
|
+
streams: list[StreamInfo] = Field(default_factory=list)
|
|
85
|
+
chapters: list[ChapterInfo] = Field(default_factory=list)
|
|
86
|
+
programs: list[ProgramInfo] = Field(default_factory=list)
|
|
87
|
+
packets: list[dict[str, Any]] = Field(default_factory=list)
|
|
88
|
+
frames: list[dict[str, Any]] = Field(default_factory=list)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Literal type aliases for ffmpeg / ffprobe CLI option values."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
VideoCodec = Literal[
|
|
7
|
+
"libx264", # H.264. Best compatibility — browsers, phones, hardware decoders all support it. Universal default.
|
|
8
|
+
"libx265", # H.265/HEVC. ~50% smaller files at the same quality, but slower to encode, weaker hardware support, and licensing concerns.
|
|
9
|
+
"libvpx-vp9", # VP9. Royalty-free, better compression than H.264. Common for WebM/YouTube; encoding is very slow.
|
|
10
|
+
"copy", # No re-encoding — source stream is muxed as-is. Use for clip cutting, container remux, and other lossless ops.
|
|
11
|
+
]
|
|
12
|
+
AudioCodec = Literal[
|
|
13
|
+
"aac", # De-facto default for MP4/MOV/TS containers. Excellent compatibility; 128–256 kbps is plenty for everyday use.
|
|
14
|
+
"libmp3lame", # MP3. Universal compatibility, but lower quality per bitrate than AAC/Opus; use only when .mp3 output is required.
|
|
15
|
+
"libopus", # Best quality per bitrate, especially for voice / low bitrates. Native in WebM/Ogg/Matroska; spotty support in MP4.
|
|
16
|
+
"copy", # No re-encoding — same semantics as VideoCodec's copy.
|
|
17
|
+
]
|
|
18
|
+
Preset = Literal[
|
|
19
|
+
"ultrafast", "superfast", "veryfast", "faster",
|
|
20
|
+
"fast", "medium", "slow", "slower", "veryslow",
|
|
21
|
+
]
|
|
22
|
+
LogLevel = Literal[
|
|
23
|
+
"quiet", "panic", "fatal", "error", "warning", "info", "verbose", "debug", "trace",
|
|
24
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ffmpeg-related helpers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def hms(s: str) -> float:
|
|
5
|
+
"""Parse `"HH:MM:SS"`, `"MM:SS"`, or `"SS"` (fractional seconds allowed) into a float second count.
|
|
6
|
+
|
|
7
|
+
Examples: `hms("00:01:30") == 90.0`, `hms("1:30.5") == 90.5`, `hms("12.5") == 12.5`.
|
|
8
|
+
"""
|
|
9
|
+
parts = s.split(":")
|
|
10
|
+
if not 1 <= len(parts) <= 3:
|
|
11
|
+
raise ValueError(f"invalid time format: {s!r}")
|
|
12
|
+
|
|
13
|
+
seconds = 0.0
|
|
14
|
+
for part in parts:
|
|
15
|
+
seconds = seconds * 60 + float(part)
|
|
16
|
+
return seconds
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Object storage abstraction with a pluggable backend, plus a Volcano TOS implementation."""
|
|
2
|
+
|
|
3
|
+
from .cli import cli
|
|
4
|
+
from .factory import obj_storage
|
|
5
|
+
from .volc_tos import VolcTosBackend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"cli", # pyproject script depends on it
|
|
10
|
+
"VolcTosBackend",
|
|
11
|
+
"obj_storage",
|
|
12
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Protocol that any object storage backend must satisfy."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from pyetool.util.monad import Result
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ObjStorageBackend(Protocol):
|
|
10
|
+
def put(self, file_path: Path | str, key: str) -> Result[str]:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
def get(self, key: str, file_path: Path | str) -> Result[Path]:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def delete(self, key: str) -> Result[None]:
|
|
17
|
+
...
|