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.
Files changed (37) hide show
  1. pyetool-0.1.0/.claude/settings.local.json +8 -0
  2. pyetool-0.1.0/.envrc +1 -0
  3. pyetool-0.1.0/.gitignore +3 -0
  4. pyetool-0.1.0/.python-version +1 -0
  5. pyetool-0.1.0/CLAUDE.md +24 -0
  6. pyetool-0.1.0/PKG-INFO +28 -0
  7. pyetool-0.1.0/README.md +15 -0
  8. pyetool-0.1.0/main.py +6 -0
  9. pyetool-0.1.0/pyetool/__init__.py +0 -0
  10. pyetool-0.1.0/pyetool/tool/__init__.py +0 -0
  11. pyetool-0.1.0/pyetool/tool/ffmpeg/__init__.py +28 -0
  12. pyetool-0.1.0/pyetool/tool/ffmpeg/ffmpeg.py +228 -0
  13. pyetool-0.1.0/pyetool/tool/ffmpeg/ffprobe.py +139 -0
  14. pyetool-0.1.0/pyetool/tool/ffmpeg/model.py +88 -0
  15. pyetool-0.1.0/pyetool/tool/ffmpeg/typing.py +24 -0
  16. pyetool-0.1.0/pyetool/tool/ffmpeg/util.py +16 -0
  17. pyetool-0.1.0/pyetool/tool/obj_storage/__init__.py +12 -0
  18. pyetool-0.1.0/pyetool/tool/obj_storage/backend.py +17 -0
  19. pyetool-0.1.0/pyetool/tool/obj_storage/cli.py +77 -0
  20. pyetool-0.1.0/pyetool/tool/obj_storage/factory.py +35 -0
  21. pyetool-0.1.0/pyetool/tool/obj_storage/volc_tos.py +70 -0
  22. pyetool-0.1.0/pyetool/tool/web_api/__init__.py +0 -0
  23. pyetool-0.1.0/pyetool/tool/web_api/pexels.py +16 -0
  24. pyetool-0.1.0/pyetool/util/__init__.py +0 -0
  25. pyetool-0.1.0/pyetool/util/env.py +8 -0
  26. pyetool-0.1.0/pyetool/util/monad.py +25 -0
  27. pyetool-0.1.0/pyetool/util/path.py +13 -0
  28. pyetool-0.1.0/pyproject.toml +65 -0
  29. pyetool-0.1.0/tests/test_ffmpeg/conftest.py +19 -0
  30. pyetool-0.1.0/tests/test_ffmpeg/data/.gitignore +5 -0
  31. pyetool-0.1.0/tests/test_ffmpeg/data/generate.py +55 -0
  32. pyetool-0.1.0/tests/test_ffmpeg/data/tiny.mp4 +0 -0
  33. pyetool-0.1.0/tests/test_ffmpeg/test_ffmpeg.py +87 -0
  34. pyetool-0.1.0/tests/test_ffmpeg/test_ffprobe.py +47 -0
  35. pyetool-0.1.0/tests/test_obj_storage/test_obj_storage.py +105 -0
  36. pyetool-0.1.0/tests/test_util/test_path.py +10 -0
  37. pyetool-0.1.0/uv.lock +741 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run *)",
5
+ "Bash(uv sync *)"
6
+ ]
7
+ }
8
+ }
pyetool-0.1.0/.envrc ADDED
@@ -0,0 +1 @@
1
+ UV_CACHE_DIR="$TMPDIR/.uv-cache"
@@ -0,0 +1,3 @@
1
+ .env
2
+
3
+ *.pyc
@@ -0,0 +1 @@
1
+ 3.13
@@ -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
@@ -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
@@ -0,0 +1,6 @@
1
+ def main():
2
+ print("Hello from pyetool!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ ...