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.
Files changed (92) hide show
  1. {videopython-0.55.0 → videopython-0.55.2}/PKG-INFO +28 -25
  2. {videopython-0.55.0 → videopython-0.55.2}/README.md +25 -23
  3. {videopython-0.55.0 → videopython-0.55.2}/pyproject.toml +28 -60
  4. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/audio/audio.py +118 -109
  5. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/effects.py +17 -26
  6. {videopython-0.55.0 → videopython-0.55.2}/.gitignore +0 -0
  7. {videopython-0.55.0 → videopython-0.55.2}/LICENSE +0 -0
  8. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/__init__.py +0 -0
  9. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/__init__.py +0 -0
  10. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_device.py +0 -0
  11. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_ollama.py +0 -0
  12. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_optional.py +0 -0
  13. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_predictor.py +0 -0
  14. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/_revisions.py +0 -0
  15. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/__init__.py +0 -0
  16. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/backend.py +0 -0
  17. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/catalog.py +0 -0
  18. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/editor.py +0 -0
  19. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/local.py +0 -0
  20. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/models.py +0 -0
  21. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/auto_edit/resolve.py +0 -0
  22. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/__init__.py +0 -0
  23. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/_tts_backend.py +0 -0
  24. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/audio_ops.py +0 -0
  25. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/config.py +0 -0
  26. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/dubber.py +0 -0
  27. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/models.py +0 -0
  28. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/pipeline.py +0 -0
  29. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/quality.py +0 -0
  30. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/remux.py +0 -0
  31. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/separation.py +0 -0
  32. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/timing.py +0 -0
  33. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/translation.py +0 -0
  34. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  35. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/effects.py +0 -0
  36. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/errors.py +0 -0
  37. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/__init__.py +0 -0
  38. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/audio.py +0 -0
  39. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/image.py +0 -0
  40. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/generation/video.py +0 -0
  41. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/keyframe.py +0 -0
  42. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/ops.py +0 -0
  43. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/transforms.py +0 -0
  44. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/__init__.py +0 -0
  45. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/_detector.py +0 -0
  46. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/audio.py +0 -0
  47. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/classification.py +0 -0
  48. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/faces.py +0 -0
  49. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/image.py +0 -0
  50. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/objects.py +0 -0
  51. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/understanding/temporal.py +0 -0
  52. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/__init__.py +0 -0
  53. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  54. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/detectors.py +0 -0
  55. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/models.py +0 -0
  56. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/sampling.py +0 -0
  57. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/ai/video_analysis/source_metadata.py +0 -0
  58. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/audio/__init__.py +0 -0
  59. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/audio/analysis.py +0 -0
  60. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/__init__.py +0 -0
  61. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/_dimensions.py +0 -0
  62. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/_ffmpeg.py +0 -0
  63. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/_video_io.py +0 -0
  64. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/description.py +0 -0
  65. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/draw_detections.py +0 -0
  66. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/exceptions.py +0 -0
  67. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  68. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  69. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  70. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  71. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  72. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  73. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  74. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  75. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  76. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  77. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/fonts/__init__.py +0 -0
  78. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/transcription.py +0 -0
  79. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/base/video.py +0 -0
  80. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/__init__.py +0 -0
  81. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/_ass.py +0 -0
  82. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/_easing.py +0 -0
  83. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/_schema.py +0 -0
  84. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/audio_ops.py +0 -0
  85. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/operation.py +0 -0
  86. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/streaming.py +0 -0
  87. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/transcription_overlay.py +0 -0
  88. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/transforms.py +0 -0
  89. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/editing/video_edit.py +0 -0
  90. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/mcp/__init__.py +0 -0
  91. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/mcp/server.py +0 -0
  92. {videopython-0.55.0 → videopython-0.55.2}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: videopython
3
- Version: 0.55.0
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 Start
72
+ ## Quick start
72
73
 
73
- ### JSON editing plans
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 ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
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 the [Automatic Editing Guide](https://videopython.com/guides/auto-editing/).
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 Agent Integration
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 (details below).
131
- 2. **`AutoEditor`** — a local Ollama vision model is the planner (see [Automatic editing](#automatic-editing-local-llm) above).
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. Install `[ai,mcp]`, run `videopython-mcp`, and point your MCP client at it. See the [MCP Server Guide](https://videopython.com/guides/mcp/).
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
- **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:
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
- See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/) for end-to-end examples (Anthropic / OpenAI tool use), the refine loop, and operation discovery.
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 (per-frame bounding boxes + labels), and the full-pipeline `VideoAnalyzer`. Scene captioning and dub translation run on a local [Ollama](https://ollama.com) model.
148
- - **`videopython.ai.auto_edit`** — `AutoEditor` + `OllamaVisionLLM`: plan and render an edit from sources + a one-line brief, with a local LLM selecting scenes by id from an auto-built catalog.
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 (analyze → catalog → validate/repair/run) so an agent drives editing.
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
- ## Examples
153
+ The docs follow [Diátaxis](https://diataxis.fr): four sections, each answering a different kind of question.
153
154
 
154
- - [Social Media Clip](https://videopython.com/examples/social-clip/)
155
- - [AI-Generated Video](https://videopython.com/examples/ai-video/)
156
- - [Auto-Subtitles](https://videopython.com/examples/auto-subtitles/)
157
- - [Processing Large Videos](https://videopython.com/examples/large-videos/)
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 contribution workflow.
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 Start
24
+ ## Quick start
25
25
 
26
- ### JSON editing plans
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 ffmpeg decode → per-frame effects → encode, so memory stays bounded even for hour-long sources. If you need the frames back in memory, load the rendered file: `Video.from_path(str(edit.run_to_file("output.mp4")))`.
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 the [Automatic Editing Guide](https://videopython.com/guides/auto-editing/).
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 Agent Integration
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 (details below).
84
- 2. **`AutoEditor`** — a local Ollama vision model is the planner (see [Automatic editing](#automatic-editing-local-llm) above).
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. Install `[ai,mcp]`, run `videopython-mcp`, and point your MCP client at it. See the [MCP Server Guide](https://videopython.com/guides/mcp/).
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
- **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:
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
- See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/) for end-to-end examples (Anthropic / OpenAI tool use), the refine loop, and operation discovery.
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 (per-frame bounding boxes + labels), and the full-pipeline `VideoAnalyzer`. Scene captioning and dub translation run on a local [Ollama](https://ollama.com) model.
101
- - **`videopython.ai.auto_edit`** — `AutoEditor` + `OllamaVisionLLM`: plan and render an edit from sources + a one-line brief, with a local LLM selecting scenes by id from an auto-built catalog.
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 (analyze → catalog → validate/repair/run) so an agent drives editing.
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
- ## Examples
105
+ The docs follow [Diátaxis](https://diataxis.fr): four sections, each answering a different kind of question.
106
106
 
107
- - [Social Media Clip](https://videopython.com/examples/social-clip/)
108
- - [AI-Generated Video](https://videopython.com/examples/ai-video/)
109
- - [Auto-Subtitles](https://videopython.com/examples/auto-subtitles/)
110
- - [Processing Large Videos](https://videopython.com/examples/large-videos/)
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 contribution workflow.
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.0"
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
- # pip install "videopython[ai]" - pip uses optional-dependencies, not dependency-groups.
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
- # whisper imports numba (timing.py), and numba is version-coupled to numpy:
70
- # each release declares a `numpy<X` cap that moves forward (0.62 -> <2.4,
71
- # 0.66 -> <2.5). Without a floor a resolver can satisfy a newer numpy by
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 — Chatterbox). Upstream chatterbox-tts
86
- # 0.1.7 pins diffusers==0.29.0 / torch==2.6.0 / transformers==5.2.0 with `==`,
87
- # which is unsatisfiable against pyannote-audio (torch>=2.8) and our
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: Qwen-Image/Wan2.2 + MusicGen (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
- # Wan2.2 i2v pipeline cleans prompts with ftfy via a hard import (diffusers'
97
- # pipeline_wan_i2v); the t2v pipeline guards it with is_ftfy_available(), i2v
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
- # No upper bound: each torchvision release declares an exact `torch==X.Y.Z`
110
- # pin, so resolvers co-select a valid pair unaided. Capping torchvision alone
111
- # is actively harmful it transitively pinned torch to 2.8 (cu12) while
112
- # torchaudio floated to 2.11 (cu13), resolving cleanly and then dying at
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 save()/load() through torchcodec, and torchcodec is a
116
- # native extension linked against libtorch that declares no torch requirement
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
- # NOTE: overrides are a uv workspace feature they do NOT ship in the built
160
- # wheel. Anything reconciled only here is invisible to `pip install videopython`,
161
- # so [ai] must be resolvable without this block. Keep it minimal.
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(cls, file_path: str | Path) -> Audio:
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
- # Convert to WAV using ffmpeg
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
- "wav",
238
+ _PCM_FORMAT,
193
239
  "-ar",
194
- str(info["sample_rate"]), # sample rate
240
+ str(target_rate),
195
241
  "-ac",
196
- str(info["channels"]), # channels
197
- "-bits_per_raw_sample",
198
- str(info["bit_depth"]),
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
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
204
- wav_data, stderr = process.communicate()
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
- if process.returncode != 0:
207
- raise AudioLoadError(f"FFmpeg error: {stderr.decode()}")
208
-
209
- # Read WAV data
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
- return cls(data, metadata)
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
- except subprocess.CalledProcessError as e:
258
- raise AudioLoadError(f"Error running ffmpeg: {e}")
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
- # Calculate resampling ratio
397
- ratio = target_sample_rate / self.metadata.sample_rate
398
-
399
- target_length = round(self.data.shape[0] * ratio)
400
-
401
- audio_array = self.data
402
- if self.metadata.channels == 1:
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
- for channel in range(self.metadata.channels):
408
- resampled_data[:, channel] = self._resample_channel(audio_array[:, channel], target_length)
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
- # Saturation as a blend toward the luma-weighted greyscale of the
315
- # frame. Replaces an RGB->HSV->RGB float32 round trip that cost more
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 the only per-frame work should be one multiply.
368
- Storing it as uint8 replicated across channels lets ``cv2.multiply`` run
369
- the whole frame in a single SIMD pass with no float conversion --
370
- ~10x faster than promoting every frame to float32, and the mask itself
371
- is *smaller* than the float32 original (6.2 MB vs 8.3 MB at 1080p).
372
-
373
- The clip to [0, 1] also fixes a real defect: ``_create_mask`` goes
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 of it.
1433
-
1434
- Generating fresh Gaussian noise per frame meant ~2M ``standard_normal``
1435
- samples every frame, which dominated the effect (~31 ms/frame, more than
1436
- twice the encoder's whole per-frame budget). Sampling a
1437
- ``GRAIN_POOL_PAD``-padded plane once and taking a randomly offset window
1438
- per frame gives grain that still changes every frame -- offsets jump
1439
- rather than drift, so it scintillates like film rather than sliding --
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