videopython 0.55.0__tar.gz → 0.55.2__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.
- {videopython-0.55.0 → videopython-0.55.2}/PKG-INFO +28 -25
- {videopython-0.55.0 → videopython-0.55.2}/README.md +25 -23
- {videopython-0.55.0 → videopython-0.55.2}/pyproject.toml +28 -60
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/audio/audio.py +118 -109
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/effects.py +17 -26
- {videopython-0.55.0 → videopython-0.55.2}/.gitignore +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/LICENSE +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_device.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_ollama.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_optional.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/backend.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/catalog.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/editor.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/local.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/models.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/resolve.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/_tts_backend.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/audio_ops.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/separation.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/translation.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/effects.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/errors.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/keyframe.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/ops.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/transforms.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/_detector.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/classification.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/detectors.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/source_metadata.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/description.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/transcription.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/video.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/_ass.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/_schema.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/audio_ops.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/operation.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/streaming.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/transcription_overlay.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/transforms.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/video_edit.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/mcp/__init__.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/mcp/server.py +0 -0
- {videopython-0.55.0 → videopython-0.55.2}/src/videopython/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: videopython
|
|
3
|
-
Version: 0.55.
|
|
3
|
+
Version: 0.55.2
|
|
4
4
|
Summary: Minimal video generation and processing library.
|
|
5
5
|
Project-URL: Homepage, https://videopython.com
|
|
6
6
|
Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
|
|
@@ -21,6 +21,7 @@ Requires-Dist: opencv-python-headless>=4.9.0.80
|
|
|
21
21
|
Requires-Dist: pillow>=12.1.1
|
|
22
22
|
Requires-Dist: pydantic>=2.8.0
|
|
23
23
|
Requires-Dist: resvg-py>=0.3.2
|
|
24
|
+
Requires-Dist: soxr>=0.5
|
|
24
25
|
Requires-Dist: tqdm>=4.66.3
|
|
25
26
|
Provides-Extra: ai
|
|
26
27
|
Requires-Dist: accelerate>=0.29.2; extra == 'ai'
|
|
@@ -66,13 +67,11 @@ pip install "videopython[ai]" # + ALL local AI features (GPU recommended)
|
|
|
66
67
|
pip install "videopython[ai,mcp]" # + MCP server for agent-driven editing
|
|
67
68
|
```
|
|
68
69
|
|
|
69
|
-
Python `>=3.11, <3.14`. AI features run locally — no cloud API keys required, but model weights are downloaded on first use. LLM-driven editing and scene captioning use a local [Ollama](https://ollama.com) server (`ollama pull qwen3.6:27b`).
|
|
70
|
+
Python `>=3.11, <3.14`. AI features run locally — no cloud API keys required, but model weights are downloaded on first use. LLM-driven editing and scene captioning use a local [Ollama](https://ollama.com) server (`ollama pull qwen3.6:27b`). See the [install guide](https://videopython.com/install/) for FFmpeg, Ollama, and hardware details.
|
|
70
71
|
|
|
71
|
-
## Quick
|
|
72
|
+
## Quick start
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
A `VideoEdit` is a multi-segment plan, defined as a dict (or JSON), validated and executed against the source files:
|
|
74
|
+
An edit is a plan — a dict (or JSON) validated and executed against the source files:
|
|
76
75
|
|
|
77
76
|
```python
|
|
78
77
|
from videopython.editing import VideoEdit
|
|
@@ -93,7 +92,7 @@ edit.validate() # dry-run via metadata, no frames loaded
|
|
|
93
92
|
edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
|
|
94
93
|
```
|
|
95
94
|
|
|
96
|
-
`run_to_file()` streams
|
|
95
|
+
`run_to_file()` streams, so memory stays bounded even for hour-long sources. Walk through it in [Tutorial 1](https://videopython.com/tutorials/first-edit/).
|
|
97
96
|
|
|
98
97
|
### Automatic editing (local LLM)
|
|
99
98
|
|
|
@@ -110,7 +109,7 @@ edit = editor.edit(
|
|
|
110
109
|
edit.run_to_file("teaser.mp4")
|
|
111
110
|
```
|
|
112
111
|
|
|
113
|
-
The model picks scenes **by id** from a catalog built from scene detection + captions, so its temporal imprecision never reaches the render. See
|
|
112
|
+
The model picks scenes **by id** from a catalog built from scene detection + captions, so its temporal imprecision never reaches the render. See [Let a local LLM edit for you](https://videopython.com/how-to/auto-editing/).
|
|
114
113
|
|
|
115
114
|
### AI generation
|
|
116
115
|
|
|
@@ -123,39 +122,43 @@ audio = TextToSpeech().generate_audio("Welcome to videopython.")
|
|
|
123
122
|
video.add_audio(audio).save("ai_video.mp4")
|
|
124
123
|
```
|
|
125
124
|
|
|
126
|
-
## LLM & AI
|
|
125
|
+
## LLM & AI agent integration
|
|
127
126
|
|
|
128
|
-
Putting an LLM in the loop works three ways:
|
|
127
|
+
Putting an LLM in the loop works three ways, differing in who owns the model:
|
|
129
128
|
|
|
130
|
-
1. **Bring your own LLM** — videopython gives your model the JSON Schema and a structured refine loop; your model authors the plans (
|
|
131
|
-
2. **`AutoEditor`** — a local Ollama vision model is the planner
|
|
132
|
-
3. **MCP server** — `videopython-mcp` exposes the pipeline as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an agent like Claude drives editing with its own model.
|
|
129
|
+
1. **Bring your own LLM** — videopython gives your model the JSON Schema and a structured refine loop; your model authors the plans. [Guide](https://videopython.com/how-to/llm-plans/)
|
|
130
|
+
2. **`AutoEditor`** — a local Ollama vision model is the planner. [Guide](https://videopython.com/how-to/auto-editing/)
|
|
131
|
+
3. **MCP server** — `videopython-mcp` exposes the pipeline as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an agent like Claude drives editing with its own model. [Guide](https://videopython.com/how-to/mcp-server/)
|
|
133
132
|
|
|
134
|
-
|
|
133
|
+
Mode 1 in brief: every operation is a Pydantic model whose fields *are* the JSON wire format, so `VideoEdit.json_schema()` hands your model a ready-made tool schema — a discriminated union over every LLM-exposed op (pass `strict=True` for provider grammar modes). Plans parse permissively and own their numeric bounds at validation, so a refine loop converges fast:
|
|
135
134
|
|
|
136
135
|
- **`edit.check(meta)`** — collect *every* structured error in one pass, not just the first
|
|
137
136
|
- **`edit.repair(meta)`** — auto-clamp mechanical violations (overruns, negatives) with a changelog
|
|
138
137
|
- **`edit.normalize_dimensions(meta, target)`** — make heterogeneous segments concat-compatible
|
|
139
138
|
|
|
140
|
-
|
|
139
|
+
Why it is built this way: [LLM-first design](https://videopython.com/explanation/llm-first-design/).
|
|
141
140
|
|
|
142
141
|
## Features
|
|
143
142
|
|
|
144
143
|
- **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
|
|
145
144
|
- **`videopython.audio`** — `Audio` with overlay, concat, normalize, time-stretch, silence detection, segment classification.
|
|
146
145
|
- **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (resize, crop, fps, speed, freeze, silence removal; cutting is the segment's own start/end) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
|
|
147
|
-
- **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect
|
|
148
|
-
- **`videopython.ai.auto_edit`** — `AutoEditor` + `OllamaVisionLLM`: plan and render an edit from sources + a one-line brief
|
|
146
|
+
- **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect, and the full-pipeline `VideoAnalyzer`. Scene captioning and dubbing translation run on a local [Ollama](https://ollama.com) model.
|
|
147
|
+
- **`videopython.ai.auto_edit`** — `AutoEditor` + `OllamaVisionLLM`: plan and render an edit from sources + a one-line brief.
|
|
149
148
|
- **`videopython.ai.dubbing`** — `VideoDubber` for voice-cloned revoicing with timing sync.
|
|
150
|
-
- **`videopython.mcp`** *(install with `[mcp]`)* — `videopython-mcp`, an MCP stdio server exposing the auto-edit pipeline
|
|
149
|
+
- **`videopython.mcp`** *(install with `[ai,mcp]`)* — `videopython-mcp`, an MCP stdio server exposing the auto-edit pipeline so an agent drives editing.
|
|
150
|
+
|
|
151
|
+
## Documentation
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
The docs follow [Diátaxis](https://diataxis.fr): four sections, each answering a different kind of question.
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
| | |
|
|
156
|
+
|---|---|
|
|
157
|
+
| [Tutorials](https://videopython.com/tutorials/) | Learn by doing — start here if you are new |
|
|
158
|
+
| [How-to guides](https://videopython.com/how-to/) | Recipes for a specific goal |
|
|
159
|
+
| [Reference](https://videopython.com/reference/) | Classes, operations, parameters, wire format |
|
|
160
|
+
| [Explanation](https://videopython.com/explanation/) | Why the library is shaped the way it is |
|
|
158
161
|
|
|
159
162
|
## Development
|
|
160
163
|
|
|
161
|
-
See [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup, testing, and
|
|
164
|
+
See [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup, testing, docs, and the release workflow.
|
|
@@ -19,13 +19,11 @@ pip install "videopython[ai]" # + ALL local AI features (GPU recommended)
|
|
|
19
19
|
pip install "videopython[ai,mcp]" # + MCP server for agent-driven editing
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Python `>=3.11, <3.14`. AI features run locally — no cloud API keys required, but model weights are downloaded on first use. LLM-driven editing and scene captioning use a local [Ollama](https://ollama.com) server (`ollama pull qwen3.6:27b`).
|
|
22
|
+
Python `>=3.11, <3.14`. AI features run locally — no cloud API keys required, but model weights are downloaded on first use. LLM-driven editing and scene captioning use a local [Ollama](https://ollama.com) server (`ollama pull qwen3.6:27b`). See the [install guide](https://videopython.com/install/) for FFmpeg, Ollama, and hardware details.
|
|
23
23
|
|
|
24
|
-
## Quick
|
|
24
|
+
## Quick start
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
A `VideoEdit` is a multi-segment plan, defined as a dict (or JSON), validated and executed against the source files:
|
|
26
|
+
An edit is a plan — a dict (or JSON) validated and executed against the source files:
|
|
29
27
|
|
|
30
28
|
```python
|
|
31
29
|
from videopython.editing import VideoEdit
|
|
@@ -46,7 +44,7 @@ edit.validate() # dry-run via metadata, no frames loaded
|
|
|
46
44
|
edit.run_to_file("output.mp4") # streams ffmpeg decode → effects → encode
|
|
47
45
|
```
|
|
48
46
|
|
|
49
|
-
`run_to_file()` streams
|
|
47
|
+
`run_to_file()` streams, so memory stays bounded even for hour-long sources. Walk through it in [Tutorial 1](https://videopython.com/tutorials/first-edit/).
|
|
50
48
|
|
|
51
49
|
### Automatic editing (local LLM)
|
|
52
50
|
|
|
@@ -63,7 +61,7 @@ edit = editor.edit(
|
|
|
63
61
|
edit.run_to_file("teaser.mp4")
|
|
64
62
|
```
|
|
65
63
|
|
|
66
|
-
The model picks scenes **by id** from a catalog built from scene detection + captions, so its temporal imprecision never reaches the render. See
|
|
64
|
+
The model picks scenes **by id** from a catalog built from scene detection + captions, so its temporal imprecision never reaches the render. See [Let a local LLM edit for you](https://videopython.com/how-to/auto-editing/).
|
|
67
65
|
|
|
68
66
|
### AI generation
|
|
69
67
|
|
|
@@ -76,39 +74,43 @@ audio = TextToSpeech().generate_audio("Welcome to videopython.")
|
|
|
76
74
|
video.add_audio(audio).save("ai_video.mp4")
|
|
77
75
|
```
|
|
78
76
|
|
|
79
|
-
## LLM & AI
|
|
77
|
+
## LLM & AI agent integration
|
|
80
78
|
|
|
81
|
-
Putting an LLM in the loop works three ways:
|
|
79
|
+
Putting an LLM in the loop works three ways, differing in who owns the model:
|
|
82
80
|
|
|
83
|
-
1. **Bring your own LLM** — videopython gives your model the JSON Schema and a structured refine loop; your model authors the plans (
|
|
84
|
-
2. **`AutoEditor`** — a local Ollama vision model is the planner
|
|
85
|
-
3. **MCP server** — `videopython-mcp` exposes the pipeline as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an agent like Claude drives editing with its own model.
|
|
81
|
+
1. **Bring your own LLM** — videopython gives your model the JSON Schema and a structured refine loop; your model authors the plans. [Guide](https://videopython.com/how-to/llm-plans/)
|
|
82
|
+
2. **`AutoEditor`** — a local Ollama vision model is the planner. [Guide](https://videopython.com/how-to/auto-editing/)
|
|
83
|
+
3. **MCP server** — `videopython-mcp` exposes the pipeline as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an agent like Claude drives editing with its own model. [Guide](https://videopython.com/how-to/mcp-server/)
|
|
86
84
|
|
|
87
|
-
|
|
85
|
+
Mode 1 in brief: every operation is a Pydantic model whose fields *are* the JSON wire format, so `VideoEdit.json_schema()` hands your model a ready-made tool schema — a discriminated union over every LLM-exposed op (pass `strict=True` for provider grammar modes). Plans parse permissively and own their numeric bounds at validation, so a refine loop converges fast:
|
|
88
86
|
|
|
89
87
|
- **`edit.check(meta)`** — collect *every* structured error in one pass, not just the first
|
|
90
88
|
- **`edit.repair(meta)`** — auto-clamp mechanical violations (overruns, negatives) with a changelog
|
|
91
89
|
- **`edit.normalize_dimensions(meta, target)`** — make heterogeneous segments concat-compatible
|
|
92
90
|
|
|
93
|
-
|
|
91
|
+
Why it is built this way: [LLM-first design](https://videopython.com/explanation/llm-first-design/).
|
|
94
92
|
|
|
95
93
|
## Features
|
|
96
94
|
|
|
97
95
|
- **`videopython.base`** — `Video`, `VideoMetadata`, `FrameIterator`, `Transcription`, and shared result types (`BoundingBox`, `FaceTrack`, `SceneBoundary`, ...). No AI dependencies.
|
|
98
96
|
- **`videopython.audio`** — `Audio` with overlay, concat, normalize, time-stretch, silence detection, segment classification.
|
|
99
97
|
- **`videopython.editing`** — `Operation`/`Effect` foundation, `VideoEdit` plan runner with JSON Schema + streaming execution. Transforms (resize, crop, fps, speed, freeze, silence removal; cutting is the segment's own start/end) and effects (blur, zoom, color grading, vignette, Ken Burns, fade, overlays, animated subtitles).
|
|
100
|
-
- **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect
|
|
101
|
-
- **`videopython.ai.auto_edit`** — `AutoEditor` + `OllamaVisionLLM`: plan and render an edit from sources + a one-line brief
|
|
98
|
+
- **`videopython.ai`** *(install with `[ai]`)* — generation (`TextToVideo`, `ImageToVideo`, `TextToImage`, `TextToSpeech`, `TextToMusic`), understanding (`AudioToText`, `AudioClassifier`, `SceneVLM`, `FaceTracker`, `ObjectDetector`, `SemanticSceneDetector`), the `FaceTrackingCrop` transform, the `ObjectDetectionOverlay` effect, and the full-pipeline `VideoAnalyzer`. Scene captioning and dubbing translation run on a local [Ollama](https://ollama.com) model.
|
|
99
|
+
- **`videopython.ai.auto_edit`** — `AutoEditor` + `OllamaVisionLLM`: plan and render an edit from sources + a one-line brief.
|
|
102
100
|
- **`videopython.ai.dubbing`** — `VideoDubber` for voice-cloned revoicing with timing sync.
|
|
103
|
-
- **`videopython.mcp`** *(install with `[mcp]`)* — `videopython-mcp`, an MCP stdio server exposing the auto-edit pipeline
|
|
101
|
+
- **`videopython.mcp`** *(install with `[ai,mcp]`)* — `videopython-mcp`, an MCP stdio server exposing the auto-edit pipeline so an agent drives editing.
|
|
102
|
+
|
|
103
|
+
## Documentation
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
The docs follow [Diátaxis](https://diataxis.fr): four sections, each answering a different kind of question.
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
| | |
|
|
108
|
+
|---|---|
|
|
109
|
+
| [Tutorials](https://videopython.com/tutorials/) | Learn by doing — start here if you are new |
|
|
110
|
+
| [How-to guides](https://videopython.com/how-to/) | Recipes for a specific goal |
|
|
111
|
+
| [Reference](https://videopython.com/reference/) | Classes, operations, parameters, wire format |
|
|
112
|
+
| [Explanation](https://videopython.com/explanation/) | Why the library is shaped the way it is |
|
|
111
113
|
|
|
112
114
|
## Development
|
|
113
115
|
|
|
114
|
-
See [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup, testing, and
|
|
116
|
+
See [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup, testing, docs, and the release workflow.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "videopython"
|
|
3
|
-
version = "0.55.
|
|
3
|
+
version = "0.55.2"
|
|
4
4
|
description = "Minimal video generation and processing library."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
|
|
@@ -32,6 +32,7 @@ classifiers = [
|
|
|
32
32
|
|
|
33
33
|
dependencies = [
|
|
34
34
|
"numpy>=1.25.2",
|
|
35
|
+
"soxr>=0.5", # polyphase resampling for Audio.resample; the engine librosa uses
|
|
35
36
|
"opencv-python-headless>=4.9.0.80",
|
|
36
37
|
"pillow>=12.1.1",
|
|
37
38
|
"resvg-py>=0.3.2",
|
|
@@ -52,26 +53,24 @@ dev = [
|
|
|
52
53
|
"mkdocs-material>=9.7.1",
|
|
53
54
|
"mkdocstrings>=1.0.0",
|
|
54
55
|
"mkdocstrings-python>=2.0.1",
|
|
56
|
+
# Keeps the pre-Diátaxis docs URLs alive (see mkdocs.yml redirect_maps).
|
|
57
|
+
# Capped <1.2.3: that release added a hard dependency on `properdocs` (an
|
|
58
|
+
# unrelated MkDocs fork) purely to print an ad during every docs build.
|
|
59
|
+
"mkdocs-redirects>=1.2,<1.2.3",
|
|
55
60
|
]
|
|
56
61
|
# Thin dev-workflow group so `uv sync` / uv.lock keep working. References the
|
|
57
62
|
# `[ai]` extra so dev installs every AI capability (single source of truth).
|
|
58
63
|
ai = ["videopython[ai]"]
|
|
59
64
|
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# A single [ai] extra installs every AI capability. The heavy ML deps still load
|
|
63
|
-
# lazily at first use (no top-level imports under ai/), so importing videopython
|
|
64
|
-
# stays light even with [ai] installed.
|
|
65
|
+
# One [ai] extra for every AI capability. Heavy ML deps load lazily at first use
|
|
66
|
+
# (no top-level imports under ai/), so importing videopython stays light.
|
|
65
67
|
[project.optional-dependencies]
|
|
66
68
|
ai = [
|
|
67
69
|
# Speech recognition / diarization (understanding/audio.py)
|
|
68
70
|
"openai-whisper>=20240930",
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
# backtracking numba to an ancient release instead, which then fails at
|
|
73
|
-
# `import whisper`. Floor it so numba's own cap does the work; 0.62 is the
|
|
74
|
-
# oldest release covering our full Python range (3.11-3.13).
|
|
71
|
+
# Floored, not capped: numba declares the numpy cap, and without a floor a
|
|
72
|
+
# resolver satisfies a newer numpy by backtracking numba instead, breaking
|
|
73
|
+
# `import whisper`. 0.62 is the oldest release covering Python 3.11-3.13.
|
|
75
74
|
"numba>=0.62",
|
|
76
75
|
"pyannote-audio>=4.0.0",
|
|
77
76
|
"silero-vad>=5.1",
|
|
@@ -82,20 +81,14 @@ ai = [
|
|
|
82
81
|
"imagehash>=4.3",
|
|
83
82
|
# Audio source separation (understanding/separation.py)
|
|
84
83
|
"demucs>=4.0.0",
|
|
85
|
-
# Voice cloning TTS (generation/audio.py
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# diffusers>=0.35 — it made `pip install "videopython[ai]"` impossible for
|
|
89
|
-
# consumers. videopython-chatterbox is upstream's source with corrected
|
|
90
|
-
# metadata (identical apart from its self-version lookup); the import name is
|
|
91
|
-
# still `chatterbox`. See https://github.com/BartWojtowicz/videopython-chatterbox
|
|
84
|
+
# Voice cloning TTS (generation/audio.py). Our fork of chatterbox-tts with
|
|
85
|
+
# corrected metadata; import name is still `chatterbox`. Not upstream — its
|
|
86
|
+
# `==` pins make [ai] unsatisfiable. See DEVELOPMENT.md "Dependencies".
|
|
92
87
|
"videopython-chatterbox>=0.1.7.post1",
|
|
93
|
-
# Local media generation
|
|
94
|
-
# >=0.35 is the floor that ships QwenImagePipeline + Wan2.2 support (tested on 0.37.1).
|
|
88
|
+
# Local media generation (generation/*). >=0.35 ships QwenImagePipeline + Wan2.2.
|
|
95
89
|
"diffusers>=0.35.0",
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# does not, so it is a required dep for ImageToVideo.
|
|
90
|
+
# Required, despite looking like diffusers' problem: the Wan2.2 i2v pipeline
|
|
91
|
+
# imports ftfy unguarded (the t2v one guards it with is_ftfy_available()).
|
|
99
92
|
"ftfy>=6.1",
|
|
100
93
|
"accelerate>=0.29.2",
|
|
101
94
|
# Dubbing loudness matching (dubbing/loudness.py)
|
|
@@ -106,16 +99,13 @@ ai = [
|
|
|
106
99
|
# processor used by the D-FINE object detector (understanding/objects.py).
|
|
107
100
|
"torch>=2.8.0",
|
|
108
101
|
"torchaudio>=2.8.0",
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# import with `OSError: libcudart.so.13`. Cap every member or none.
|
|
102
|
+
# Do NOT add an upper bound. torchvision pins an exact torch, so capping it
|
|
103
|
+
# alone pinned torch to 2.8 (cu12) while torchaudio floated to 2.11 (cu13) —
|
|
104
|
+
# resolved cleanly, then died at import with `OSError: libcudart.so.13`.
|
|
105
|
+
# Cap every member of the torch stack or none.
|
|
114
106
|
"torchvision>=0.23.0",
|
|
115
|
-
# torchaudio 2.11 routes
|
|
116
|
-
#
|
|
117
|
-
# of its own. It arrives transitively via pyannote-audio; declare it so it is
|
|
118
|
-
# visible rather than incidental.
|
|
107
|
+
# Transitive via pyannote-audio, declared because torchaudio 2.11+ routes
|
|
108
|
+
# save()/load() through it and it is ABI-coupled to torch.
|
|
119
109
|
"torchcodec>=0.7.0",
|
|
120
110
|
]
|
|
121
111
|
# MCP server (videopython/mcp/). Pin <2 — v2 is pre-release/breaking.
|
|
@@ -150,36 +140,17 @@ module = [
|
|
|
150
140
|
"silero_vad", "silero_vad.*",
|
|
151
141
|
"cv2", "cv2.*",
|
|
152
142
|
"pyloudnorm", "pyloudnorm.*",
|
|
143
|
+
"soxr", "soxr.*",
|
|
153
144
|
"imagehash", "imagehash.*",
|
|
154
145
|
"mcp", "mcp.*",
|
|
155
146
|
]
|
|
156
147
|
ignore_missing_imports = true
|
|
157
148
|
|
|
158
149
|
[tool.uv]
|
|
159
|
-
#
|
|
160
|
-
# wheel
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
# The torch/torchaudio/torchvision/diffusers overrides that used to live here
|
|
164
|
-
# existed solely to paper over chatterbox-tts's `==` pins. They masked the
|
|
165
|
-
# breakage from CI while every downstream consumer hit it, and an override
|
|
166
|
-
# replaces a requirement *everywhere* — including torchvision's own exact
|
|
167
|
-
# `torch==2.8.0` pin, which would have produced an ABI-invalid pair on the next
|
|
168
|
-
# `uv lock --upgrade`. [ai] now depends on videopython-chatterbox, whose metadata
|
|
169
|
-
# is correct, so none of them are needed.
|
|
170
|
-
# The numpy>=2.0.0 override is gone for the same reason: it existed to counter
|
|
171
|
-
# chatterbox-tts's `numpy<2.0.0` pin, and it also replaced numba's `numpy<2.5`
|
|
172
|
-
# cap, resolving numpy 2.5 into an environment where `import whisper` dies with
|
|
173
|
-
# "Numba needs NumPy 2.4 or less". numba IS version-coupled to numpy.
|
|
174
|
-
override-dependencies = [
|
|
175
|
-
# Some transitive deps pull opencv-python, which conflicts with our
|
|
176
|
-
# opencv-python-headless (both provide cv2). Exclude opencv-python so
|
|
177
|
-
# only the headless variant is installed.
|
|
178
|
-
"opencv-python ; sys_platform == '_'",
|
|
179
|
-
]
|
|
180
|
-
# Pin minimum versions for transitive deps with known vulnerabilities.
|
|
181
|
-
# Pygments 2.20.0 has a security fix but breaks mkdocs (passes None to html.escape).
|
|
182
|
-
# Keep pygments<2.20.0 until a compatible release is available.
|
|
150
|
+
# No overrides, deliberately: they are a uv workspace feature and do not ship in
|
|
151
|
+
# the wheel, so anything patched here is invisible to `pip install`. Fix the
|
|
152
|
+
# dependency's metadata instead — see DEVELOPMENT.md "Dependencies".
|
|
153
|
+
# requests: CVE floor. pygments <2.20.0: 2.20.0 breaks mkdocs (passes None to html.escape).
|
|
183
154
|
constraint-dependencies = ["requests>=2.33.0", "pygments>=2.19.2,<2.20.0"]
|
|
184
155
|
|
|
185
156
|
[build-system]
|
|
@@ -207,9 +178,6 @@ pythonpath = ["src/"]
|
|
|
207
178
|
testpaths = ["src/tests"]
|
|
208
179
|
python_files = ["test_*.py"]
|
|
209
180
|
addopts = ["-v", "--tb=short"]
|
|
210
|
-
markers = [
|
|
211
|
-
"requires_model_download: marks tests that require downloading ML models (skipped in CI)",
|
|
212
|
-
]
|
|
213
181
|
|
|
214
182
|
[tool.ruff]
|
|
215
183
|
line-length = 120
|
|
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
4
|
import subprocess
|
|
5
|
+
import tempfile
|
|
5
6
|
import wave
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import TYPE_CHECKING, Any
|
|
9
10
|
|
|
10
11
|
import numpy as np
|
|
12
|
+
import soxr
|
|
11
13
|
|
|
12
14
|
from videopython.base import _ffmpeg
|
|
13
15
|
from videopython.base.exceptions import AudioLoadError, FFmpegProbeError
|
|
@@ -15,6 +17,17 @@ from videopython.base.exceptions import AudioLoadError, FFmpegProbeError
|
|
|
15
17
|
if TYPE_CHECKING:
|
|
16
18
|
from videopython.audio.analysis import AudioLevels, AudioSegment, AudioSegmentType, SilentSegment
|
|
17
19
|
|
|
20
|
+
# What `Audio.from_path` asks ffmpeg for. Piped WAV comes back as pcm_s16le whatever
|
|
21
|
+
# the source's bit depth, so requesting raw s16le loses no fidelity that the WAV path
|
|
22
|
+
# was preserving, and there is no header to parse back.
|
|
23
|
+
_PCM_FORMAT = "s16le"
|
|
24
|
+
_PCM_DTYPE = np.int16
|
|
25
|
+
_PCM_SAMPLE_WIDTH = 2
|
|
26
|
+
|
|
27
|
+
# Read size when draining ffmpeg's stdout: big enough that a multi-GB decode is not
|
|
28
|
+
# millions of round trips, small enough to be irrelevant for a short clip.
|
|
29
|
+
_DECODE_CHUNK_BYTES = 8 << 20
|
|
30
|
+
|
|
18
31
|
|
|
19
32
|
def atempo_chain(speed: float) -> list[str]:
|
|
20
33
|
"""Build the ``atempo`` filter chain that time-stretches audio by ``speed``.
|
|
@@ -162,100 +175,131 @@ class Audio:
|
|
|
162
175
|
return cls(data, metadata)
|
|
163
176
|
|
|
164
177
|
@classmethod
|
|
165
|
-
def from_path(
|
|
178
|
+
def from_path(
|
|
179
|
+
cls,
|
|
180
|
+
file_path: str | Path,
|
|
181
|
+
*,
|
|
182
|
+
sample_rate: int | None = None,
|
|
183
|
+
channels: int | None = None,
|
|
184
|
+
) -> Audio:
|
|
166
185
|
"""
|
|
167
|
-
Load audio from a file using ffmpeg
|
|
186
|
+
Load audio from a file using ffmpeg.
|
|
187
|
+
|
|
188
|
+
``sample_rate`` and ``channels`` ask ffmpeg to convert *while decoding*
|
|
189
|
+
rather than loading the source in full and converting afterwards. For a
|
|
190
|
+
caller that only wants 16kHz mono -- speech recognition, diarization,
|
|
191
|
+
speaker embeddings -- that is the difference between holding the whole
|
|
192
|
+
source in memory and holding a twelfth of it: a 12-hour 48kHz stereo
|
|
193
|
+
recording is 16.5GB of float32 at source rate and 1.4GB at 16kHz mono.
|
|
194
|
+
Resampling uses soxr, the engine :meth:`resample` uses, so the result
|
|
195
|
+
tracks ``Audio.from_path(p).to_mono().resample(r)`` sample for sample at
|
|
196
|
+
an error RMS around one 16-bit LSB -- the two quantize at different points
|
|
197
|
+
in the chain, and neither is the more faithful for it.
|
|
168
198
|
|
|
169
199
|
Args:
|
|
170
200
|
file_path: Path to the audio file
|
|
201
|
+
sample_rate: Decode at this rate instead of the source's.
|
|
202
|
+
channels: Decode to this many channels instead of the source's.
|
|
203
|
+
``1`` downmixes to mono.
|
|
171
204
|
|
|
172
205
|
Returns:
|
|
173
206
|
Audio: New Audio instance
|
|
174
207
|
|
|
175
208
|
Raises:
|
|
176
209
|
FileNotFoundError: If the file doesn't exist
|
|
210
|
+
ValueError: If ``sample_rate`` or ``channels`` is not positive
|
|
177
211
|
AudioLoadError: If there's an error loading the audio
|
|
178
212
|
"""
|
|
179
213
|
file_path = Path(file_path)
|
|
180
214
|
if not file_path.exists():
|
|
181
215
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
216
|
+
if sample_rate is not None and sample_rate <= 0:
|
|
217
|
+
raise ValueError("Sample rate must be positive")
|
|
218
|
+
if channels is not None and channels <= 0:
|
|
219
|
+
raise ValueError("Channel count must be positive")
|
|
182
220
|
|
|
183
|
-
# Get audio info
|
|
184
221
|
info = cls._get_ffmpeg_info(file_path)
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
target_rate = info["sample_rate"] if sample_rate is None else sample_rate
|
|
223
|
+
target_channels = info["channels"] if channels is None else channels
|
|
224
|
+
|
|
225
|
+
# Raw PCM rather than a WAV round-trip. Piped WAV comes back as pcm_s16le
|
|
226
|
+
# whatever the source's bit depth -- `-bits_per_raw_sample` is a hint the
|
|
227
|
+
# WAV muxer does not act on -- and its header carries a placeholder length
|
|
228
|
+
# because a pipe is not seekable. So parsing it back told us only what we
|
|
229
|
+
# had already asked for, and cost two more full copies of the audio on the
|
|
230
|
+
# way: one for `BytesIO`, one for `readframes`.
|
|
187
231
|
cmd = [
|
|
188
232
|
"ffmpeg",
|
|
233
|
+
"-v",
|
|
234
|
+
"error",
|
|
189
235
|
"-i",
|
|
190
236
|
str(file_path),
|
|
191
237
|
"-f",
|
|
192
|
-
|
|
238
|
+
_PCM_FORMAT,
|
|
193
239
|
"-ar",
|
|
194
|
-
str(
|
|
240
|
+
str(target_rate),
|
|
195
241
|
"-ac",
|
|
196
|
-
str(
|
|
197
|
-
|
|
198
|
-
|
|
242
|
+
str(target_channels),
|
|
243
|
+
# soxr rather than ffmpeg's default resampler, so that decoding at a
|
|
244
|
+
# rate and resampling to it afterwards agree.
|
|
245
|
+
"-af",
|
|
246
|
+
"aresample=resampler=soxr",
|
|
199
247
|
"-", # Output to stdout
|
|
200
248
|
]
|
|
201
249
|
|
|
202
250
|
try:
|
|
203
|
-
|
|
204
|
-
|
|
251
|
+
# stderr to a file, not a pipe: stdout is drained to completion before
|
|
252
|
+
# stderr is read, and a full stderr pipe would deadlock ffmpeg partway
|
|
253
|
+
# through the audio.
|
|
254
|
+
with tempfile.TemporaryFile() as errors:
|
|
255
|
+
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=errors)
|
|
256
|
+
assert process.stdout is not None
|
|
257
|
+
# A bytearray rather than `communicate()`, which accumulates chunks
|
|
258
|
+
# in a list and joins them at the end -- holding the whole of a long
|
|
259
|
+
# decode twice at the moment it completes.
|
|
260
|
+
raw = bytearray()
|
|
261
|
+
try:
|
|
262
|
+
while chunk := process.stdout.read(_DECODE_CHUNK_BYTES):
|
|
263
|
+
raw += chunk
|
|
264
|
+
finally:
|
|
265
|
+
process.stdout.close()
|
|
266
|
+
if process.wait() != 0:
|
|
267
|
+
errors.seek(0)
|
|
268
|
+
raise AudioLoadError(f"FFmpeg error: {errors.read().decode(errors='replace')}")
|
|
269
|
+
except subprocess.CalledProcessError as e:
|
|
270
|
+
raise AudioLoadError(f"Error running ffmpeg: {e}")
|
|
205
271
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
with io.BytesIO(wav_data) as wav_io:
|
|
211
|
-
with wave.open(wav_io, "rb") as wav_file:
|
|
212
|
-
# Get WAV metadata
|
|
213
|
-
sample_width = wav_file.getsampwidth()
|
|
214
|
-
channels = wav_file.getnchannels()
|
|
215
|
-
sample_rate = wav_file.getframerate()
|
|
216
|
-
n_frames = wav_file.getnframes()
|
|
217
|
-
|
|
218
|
-
# Read raw audio data
|
|
219
|
-
raw_data = wav_file.readframes(n_frames)
|
|
220
|
-
|
|
221
|
-
# Convert bytes to numpy array based on sample width
|
|
222
|
-
dtype_map = {1: np.int8, 2: np.int16, 4: np.int32}
|
|
223
|
-
dtype = dtype_map.get(sample_width)
|
|
224
|
-
if dtype is None:
|
|
225
|
-
raise AudioLoadError(f"Unsupported sample width: {sample_width}")
|
|
226
|
-
|
|
227
|
-
# Explicitly annotated: numpy>=2.5 shape-types ndarray, so the
|
|
228
|
-
# 1-D frombuffer result cannot be rebound to a 2-D view below.
|
|
229
|
-
data: np.ndarray[Any, np.dtype[np.float32]]
|
|
230
|
-
data = np.frombuffer(raw_data, dtype=dtype).astype(np.float32)
|
|
231
|
-
|
|
232
|
-
# Reshape to (frames, channels) if stereo
|
|
233
|
-
if channels == 2:
|
|
234
|
-
data = data.reshape(-1, 2)
|
|
235
|
-
|
|
236
|
-
# Normalize to float between -1 and 1
|
|
237
|
-
max_value = float(np.iinfo(dtype).max) # type: ignore
|
|
238
|
-
data = data / max_value
|
|
239
|
-
|
|
240
|
-
# Ensure normalization is within bounds due to floating point precision
|
|
241
|
-
data = np.clip(data, -1.0, 1.0)
|
|
242
|
-
|
|
243
|
-
# Calculate frame count from actual data length
|
|
244
|
-
# For stereo, len(data) is already correct after reshape
|
|
245
|
-
frame_count = len(data)
|
|
246
|
-
|
|
247
|
-
metadata = AudioMetadata(
|
|
248
|
-
sample_rate=sample_rate,
|
|
249
|
-
channels=channels,
|
|
250
|
-
sample_width=sample_width,
|
|
251
|
-
duration_seconds=info["duration"],
|
|
252
|
-
frame_count=frame_count,
|
|
253
|
-
)
|
|
272
|
+
# A truncated final frame would otherwise make `frombuffer` raise on a file
|
|
273
|
+
# that is entirely usable up to that point.
|
|
274
|
+
frame_bytes = _PCM_SAMPLE_WIDTH * target_channels
|
|
275
|
+
usable = len(raw) - (len(raw) % frame_bytes)
|
|
254
276
|
|
|
255
|
-
|
|
277
|
+
# Explicitly annotated: numpy>=2.5 shape-types ndarray, so the
|
|
278
|
+
# 1-D frombuffer result cannot be rebound to a 2-D view below.
|
|
279
|
+
data: np.ndarray[Any, np.dtype[np.float32]]
|
|
280
|
+
data = np.frombuffer(memoryview(raw)[:usable], dtype=_PCM_DTYPE).astype(np.float32)
|
|
256
281
|
|
|
257
|
-
|
|
258
|
-
|
|
282
|
+
# Reshape to (frames, channels) if stereo
|
|
283
|
+
if target_channels == 2:
|
|
284
|
+
data = data.reshape(-1, 2)
|
|
285
|
+
|
|
286
|
+
# Normalize to float between -1 and 1, and clamp for floating-point
|
|
287
|
+
# precision. Both in place: at these sizes a copy per step is most of what
|
|
288
|
+
# makes decoding a long file expensive.
|
|
289
|
+
data /= float(np.iinfo(_PCM_DTYPE).max)
|
|
290
|
+
np.clip(data, -1.0, 1.0, out=data)
|
|
291
|
+
|
|
292
|
+
# Calculate frame count from actual data length
|
|
293
|
+
# For stereo, len(data) is already correct after reshape
|
|
294
|
+
metadata = AudioMetadata(
|
|
295
|
+
sample_rate=target_rate,
|
|
296
|
+
channels=target_channels,
|
|
297
|
+
sample_width=_PCM_SAMPLE_WIDTH,
|
|
298
|
+
duration_seconds=info["duration"],
|
|
299
|
+
frame_count=len(data),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return cls(data, metadata)
|
|
259
303
|
|
|
260
304
|
@classmethod
|
|
261
305
|
def from_file(cls, file_path: str | Path) -> Audio:
|
|
@@ -384,6 +428,8 @@ class Audio:
|
|
|
384
428
|
"""
|
|
385
429
|
Resample the audio to a new sample rate
|
|
386
430
|
|
|
431
|
+
Uses `soxr` (band-limited polyphase), the same engine librosa resamples with.
|
|
432
|
+
|
|
387
433
|
Args:
|
|
388
434
|
target_sample_rate: New sample rate in Hz
|
|
389
435
|
|
|
@@ -393,19 +439,16 @@ class Audio:
|
|
|
393
439
|
if target_sample_rate == self.metadata.sample_rate:
|
|
394
440
|
return self
|
|
395
441
|
|
|
396
|
-
#
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
audio_array = audio_array.reshape(-1, 1)
|
|
404
|
-
|
|
405
|
-
resampled_data = np.zeros((target_length, self.metadata.channels), dtype=np.float32)
|
|
442
|
+
# soxr takes mono (1-D) and interleaved stereo directly. "HQ" is librosa's default.
|
|
443
|
+
resampled_data = soxr.resample(
|
|
444
|
+
self.data,
|
|
445
|
+
self.metadata.sample_rate,
|
|
446
|
+
target_sample_rate,
|
|
447
|
+
quality="HQ",
|
|
448
|
+
).astype(np.float32)
|
|
406
449
|
|
|
407
|
-
|
|
408
|
-
|
|
450
|
+
# From the resampler, not a predicted round(n * ratio): metadata must match the array.
|
|
451
|
+
target_length = resampled_data.shape[0]
|
|
409
452
|
|
|
410
453
|
new_metadata = AudioMetadata(
|
|
411
454
|
sample_rate=target_sample_rate,
|
|
@@ -414,41 +457,9 @@ class Audio:
|
|
|
414
457
|
duration_seconds=target_length / target_sample_rate,
|
|
415
458
|
frame_count=target_length,
|
|
416
459
|
)
|
|
417
|
-
if self.metadata.channels == 1:
|
|
418
|
-
resampled_data = resampled_data.flatten()
|
|
419
460
|
|
|
420
461
|
return Audio(resampled_data, new_metadata)
|
|
421
462
|
|
|
422
|
-
@staticmethod
|
|
423
|
-
def _resample_channel(data: np.ndarray, new_length: int) -> np.ndarray:
|
|
424
|
-
"""Resample a single channel of audio data to a new length"""
|
|
425
|
-
|
|
426
|
-
data_fourier = np.fft.rfft(data)
|
|
427
|
-
original_length = data.shape[0]
|
|
428
|
-
|
|
429
|
-
newshape = [new_length // 2 + 1]
|
|
430
|
-
|
|
431
|
-
data_fourier_placeholder = np.zeros(newshape, data_fourier.dtype)
|
|
432
|
-
|
|
433
|
-
min_length = min(new_length, original_length)
|
|
434
|
-
nyquist = min_length // 2 + 1
|
|
435
|
-
sl = [slice(0, nyquist)]
|
|
436
|
-
data_fourier_placeholder[tuple(sl)] = data_fourier[tuple(sl)]
|
|
437
|
-
|
|
438
|
-
if min_length % 2 == 0:
|
|
439
|
-
if new_length < original_length:
|
|
440
|
-
sl = [slice(min_length // 2, min_length // 2 + 1)]
|
|
441
|
-
data_fourier_placeholder[tuple(sl)] *= 2.0
|
|
442
|
-
|
|
443
|
-
sl = [slice(min_length // 2, min_length // 2 + 1)]
|
|
444
|
-
data_fourier_placeholder[tuple(sl)] *= 0.5
|
|
445
|
-
|
|
446
|
-
resampled_data = np.fft.irfft(data_fourier_placeholder, new_length)
|
|
447
|
-
|
|
448
|
-
resampled_data *= float(new_length) / float(original_length)
|
|
449
|
-
|
|
450
|
-
return resampled_data
|
|
451
|
-
|
|
452
463
|
def concat(self, other: Audio, crossfade: float = 0.0) -> Audio:
|
|
453
464
|
"""
|
|
454
465
|
Concatenate another audio segment to this one.
|
|
@@ -685,8 +696,6 @@ class Audio:
|
|
|
685
696
|
filter_str = ",".join(filters) if filters else "anull"
|
|
686
697
|
|
|
687
698
|
# Save current audio to temp WAV, process with ffmpeg, read back
|
|
688
|
-
import tempfile
|
|
689
|
-
|
|
690
699
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as input_file:
|
|
691
700
|
input_path = input_file.name
|
|
692
701
|
|
|
@@ -311,9 +311,8 @@ class ColorGrading(Effect):
|
|
|
311
311
|
if self.brightness != 0 or self.contrast != 1.0:
|
|
312
312
|
out = cv2.LUT(out, self._lut_tone)
|
|
313
313
|
if self.saturation != 1.0:
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
# than every other stage of the grade combined (~42 of 56 ms/frame).
|
|
314
|
+
# Blend toward luma-weighted greyscale. Not an HSV round trip: that
|
|
315
|
+
# costs more than every other stage of the grade combined.
|
|
317
316
|
grey = cv2.cvtColor(out, cv2.COLOR_RGB2GRAY)
|
|
318
317
|
out = cv2.addWeighted(
|
|
319
318
|
out, self.saturation, cv2.cvtColor(grey, cv2.COLOR_GRAY2RGB), 1.0 - self.saturation, 0
|
|
@@ -364,17 +363,13 @@ class Vignette(Effect):
|
|
|
364
363
|
def streaming_init(self, total_frames: int, fps: float, width: int, height: int, **_context: Any) -> None:
|
|
365
364
|
"""Bake the gain mask into a 3-channel uint8 lookup, once per stream.
|
|
366
365
|
|
|
367
|
-
The mask is static, so
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
NEGATIVE once ``strength`` is high enough (down to -1.0 at
|
|
375
|
-
``strength=1.0``), and ``(frame * -1.0).astype(np.uint8)`` wraps around,
|
|
376
|
-
so the darkest corners rendered as mid-grey (200 -> 56) and the vignette
|
|
377
|
-
got *brighter* past the zero crossing instead of saturating to black.
|
|
366
|
+
The mask is static, so per-frame work is one ``cv2.multiply`` SIMD pass
|
|
367
|
+
with no float conversion.
|
|
368
|
+
|
|
369
|
+
Clipping to [0, 1] is load-bearing, not tidying: ``_create_mask`` goes
|
|
370
|
+
negative once ``strength`` is high enough (-1.0 at ``strength=1.0``), and
|
|
371
|
+
``(frame * -1.0).astype(np.uint8)`` wraps, which renders the darkest
|
|
372
|
+
corners mid-grey and makes the vignette brighten past the zero crossing.
|
|
378
373
|
"""
|
|
379
374
|
if self._mask is None or self._mask.shape != (height, width):
|
|
380
375
|
self._mask = self._create_mask(height, width)
|
|
@@ -1429,18 +1424,14 @@ class FilmGrain(Effect):
|
|
|
1429
1424
|
_geometry: tuple[int, int] = PrivateAttr(default=(0, 0))
|
|
1430
1425
|
|
|
1431
1426
|
def streaming_init(self, total_frames: int, fps: float, width: int, height: int, **_context: Any) -> None:
|
|
1432
|
-
"""Draw one oversized noise plane up front; each frame reads a random window
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
for one saturating integer add.
|
|
1441
|
-
|
|
1442
|
-
Reproducibility is unchanged in contract (same ``seed`` -> same grain),
|
|
1443
|
-
though the pattern itself differs from the per-frame-RNG version.
|
|
1427
|
+
"""Draw one oversized noise plane up front; each frame reads a random window.
|
|
1428
|
+
|
|
1429
|
+
Sampling Gaussian noise per frame costs ~2M draws a frame and dominates
|
|
1430
|
+
the effect; one padded plane plus a per-frame offset reduces that to a
|
|
1431
|
+
saturating integer add.
|
|
1432
|
+
|
|
1433
|
+
Offsets jump rather than advance, so the grain scintillates like film
|
|
1434
|
+
instead of sliding. Same ``seed`` still gives the same grain.
|
|
1444
1435
|
"""
|
|
1445
1436
|
amp = self.intensity * 255.0
|
|
1446
1437
|
pad = GRAIN_POOL_PAD
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/classification.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/source_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|