videopython 0.46.0__tar.gz → 0.48.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {videopython-0.46.0 → videopython-0.48.0}/PKG-INFO +2 -45
  2. {videopython-0.46.0 → videopython-0.48.0}/README.md +0 -2
  3. {videopython-0.46.0 → videopython-0.48.0}/pyproject.toml +24 -74
  4. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/__init__.py +21 -0
  5. videopython-0.48.0/src/videopython/ai/auto_edit/__init__.py +29 -0
  6. videopython-0.48.0/src/videopython/ai/auto_edit/backend.py +37 -0
  7. videopython-0.48.0/src/videopython/ai/auto_edit/catalog.py +90 -0
  8. videopython-0.48.0/src/videopython/ai/auto_edit/editor.py +140 -0
  9. videopython-0.48.0/src/videopython/ai/auto_edit/local.py +67 -0
  10. videopython-0.48.0/src/videopython/ai/auto_edit/models.py +105 -0
  11. videopython-0.48.0/src/videopython/ai/auto_edit/resolve.py +34 -0
  12. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/loudness.py +1 -1
  13. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/effects.py +9 -4
  14. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/audio.py +2 -2
  15. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/image.py +1 -1
  16. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/qwen3.py +1 -1
  17. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/translation.py +1 -1
  18. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/video.py +2 -4
  19. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/transforms.py +0 -1
  20. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/audio.py +5 -5
  21. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/faces.py +1 -1
  22. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/image.py +2 -2
  23. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/objects.py +1 -1
  24. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/separation.py +1 -1
  25. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/temporal.py +1 -1
  26. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/sampling.py +1 -1
  27. videopython-0.48.0/src/videopython/editing/_schema.py +42 -0
  28. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/effects.py +0 -372
  29. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/operation.py +30 -20
  30. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/streaming.py +18 -26
  31. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/transcription_overlay.py +0 -1
  32. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/transforms.py +0 -6
  33. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/video_edit.py +23 -68
  34. {videopython-0.46.0 → videopython-0.48.0}/.gitignore +0 -0
  35. {videopython-0.46.0 → videopython-0.48.0}/LICENSE +0 -0
  36. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/__init__.py +0 -0
  37. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_device.py +0 -0
  38. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_optional.py +0 -0
  39. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_predictor.py +0 -0
  40. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_revisions.py +0 -0
  41. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  42. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/config.py +0 -0
  43. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  44. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  45. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/models.py +0 -0
  46. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  47. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/quality.py +0 -0
  48. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/remux.py +0 -0
  49. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/timing.py +0 -0
  50. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  51. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/__init__.py +0 -0
  52. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
  53. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/__init__.py +0 -0
  54. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  55. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  56. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/models.py +0 -0
  57. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  58. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/audio/__init__.py +0 -0
  59. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/audio/analysis.py +0 -0
  60. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/audio/audio.py +0 -0
  61. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/__init__.py +0 -0
  62. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/_dimensions.py +0 -0
  63. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/_ffmpeg.py +0 -0
  64. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/_video_io.py +0 -0
  65. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/description.py +0 -0
  66. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/draw_detections.py +0 -0
  67. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/exceptions.py +0 -0
  68. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  69. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  70. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  71. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  72. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  73. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  74. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  75. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  76. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  77. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  78. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/__init__.py +0 -0
  79. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/transcription.py +0 -0
  80. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/video.py +0 -0
  81. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/__init__.py +0 -0
  82. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/_ass.py +0 -0
  83. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/_easing.py +0 -0
  84. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/audio_ops.py +0 -0
  85. {videopython-0.46.0 → videopython-0.48.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.46.0
3
+ Version: 0.48.0
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/
@@ -29,6 +29,7 @@ Requires-Dist: demucs>=4.0.0; extra == 'ai'
29
29
  Requires-Dist: diffusers>=0.30.0; extra == 'ai'
30
30
  Requires-Dist: imagehash>=4.3; extra == 'ai'
31
31
  Requires-Dist: llama-cpp-python>=0.3.0; extra == 'ai'
32
+ Requires-Dist: ollama>=0.5.0; extra == 'ai'
32
33
  Requires-Dist: openai-whisper>=20240930; extra == 'ai'
33
34
  Requires-Dist: pyannote-audio>=4.0.0; extra == 'ai'
34
35
  Requires-Dist: pyloudnorm>=0.1.1; extra == 'ai'
@@ -40,48 +41,6 @@ Requires-Dist: torchaudio>=2.8.0; extra == 'ai'
40
41
  Requires-Dist: transformers>=5.2.0; extra == 'ai'
41
42
  Requires-Dist: transnetv2-pytorch>=1.0.5; extra == 'ai'
42
43
  Requires-Dist: ultralytics>=8.0.0; extra == 'ai'
43
- Provides-Extra: asr
44
- Requires-Dist: openai-whisper>=20240930; extra == 'asr'
45
- Requires-Dist: pyannote-audio>=4.0.0; extra == 'asr'
46
- Requires-Dist: silero-vad>=5.1; extra == 'asr'
47
- Requires-Dist: torch>=2.8.0; extra == 'asr'
48
- Requires-Dist: torchaudio>=2.8.0; extra == 'asr'
49
- Provides-Extra: dub
50
- Requires-Dist: demucs>=4.0.0; extra == 'dub'
51
- Requires-Dist: llama-cpp-python>=0.3.0; extra == 'dub'
52
- Requires-Dist: openai-whisper>=20240930; extra == 'dub'
53
- Requires-Dist: pyannote-audio>=4.0.0; extra == 'dub'
54
- Requires-Dist: pyloudnorm>=0.1.1; extra == 'dub'
55
- Requires-Dist: sentencepiece>=0.1.99; extra == 'dub'
56
- Requires-Dist: silero-vad>=5.1; extra == 'dub'
57
- Requires-Dist: torch>=2.8.0; extra == 'dub'
58
- Requires-Dist: torchaudio>=2.8.0; extra == 'dub'
59
- Requires-Dist: transformers>=5.2.0; extra == 'dub'
60
- Provides-Extra: generation
61
- Requires-Dist: accelerate>=0.29.2; extra == 'generation'
62
- Requires-Dist: diffusers>=0.30.0; extra == 'generation'
63
- Requires-Dist: torch>=2.8.0; extra == 'generation'
64
- Requires-Dist: torchaudio>=2.8.0; extra == 'generation'
65
- Requires-Dist: transformers>=5.2.0; extra == 'generation'
66
- Provides-Extra: separation
67
- Requires-Dist: demucs>=4.0.0; extra == 'separation'
68
- Requires-Dist: torch>=2.8.0; extra == 'separation'
69
- Requires-Dist: torchaudio>=2.8.0; extra == 'separation'
70
- Provides-Extra: translation
71
- Requires-Dist: llama-cpp-python>=0.3.0; extra == 'translation'
72
- Requires-Dist: sentencepiece>=0.1.99; extra == 'translation'
73
- Requires-Dist: torch>=2.8.0; extra == 'translation'
74
- Requires-Dist: transformers>=5.2.0; extra == 'translation'
75
- Provides-Extra: tts
76
- Requires-Dist: chatterbox-tts>=0.1.7; extra == 'tts'
77
- Provides-Extra: vision
78
- Requires-Dist: imagehash>=4.3; extra == 'vision'
79
- Requires-Dist: qwen-vl-utils>=0.0.10; extra == 'vision'
80
- Requires-Dist: torch>=2.8.0; extra == 'vision'
81
- Requires-Dist: torchaudio>=2.8.0; extra == 'vision'
82
- Requires-Dist: transformers>=5.2.0; extra == 'vision'
83
- Requires-Dist: transnetv2-pytorch>=1.0.5; extra == 'vision'
84
- Requires-Dist: ultralytics>=8.0.0; extra == 'vision'
85
44
  Description-Content-Type: text/markdown
86
45
 
87
46
  # videopython
@@ -104,8 +63,6 @@ pip install videopython # core video/audio editing
104
63
  pip install "videopython[ai]" # + ALL local AI features (GPU recommended)
105
64
  ```
106
65
 
107
- AI deps are also split into granular extras so you can install only what you need: `[asr]` (transcription), `[vision]` (detection/scene/VLM), `[separation]`, `[translation]`, `[tts]` (voice cloning), `[generation]` (image/video/music), and `[dub]` (the dubbing pipeline). `[dub]` excludes chatterbox — add `[tts]` for local synthesis (`pip install "videopython[dub,tts]"`) or inject a `SpeechBackend`. See the [Installation Guide](https://videopython.com/getting-started/installation/) for the full table.
108
-
109
66
  Python `>=3.11, <3.14`. AI features run locally — no cloud API keys required, but model weights are downloaded on first use.
110
67
 
111
68
  ## Quick Start
@@ -18,8 +18,6 @@ pip install videopython # core video/audio editing
18
18
  pip install "videopython[ai]" # + ALL local AI features (GPU recommended)
19
19
  ```
20
20
 
21
- AI deps are also split into granular extras so you can install only what you need: `[asr]` (transcription), `[vision]` (detection/scene/VLM), `[separation]`, `[translation]`, `[tts]` (voice cloning), `[generation]` (image/video/music), and `[dub]` (the dubbing pipeline). `[dub]` excludes chatterbox — add `[tts]` for local synthesis (`pip install "videopython[dub,tts]"`) or inject a `SpeechBackend`. See the [Installation Guide](https://videopython.com/getting-started/installation/) for the full table.
22
-
23
21
  Python `>=3.11, <3.14`. AI features run locally — no cloud API keys required, but model weights are downloaded on first use.
24
22
 
25
23
  ## Quick Start
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.46.0"
3
+ version = "0.48.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -53,90 +53,46 @@ dev = [
53
53
  "mkdocstrings>=1.0.0",
54
54
  "mkdocstrings-python>=2.0.1",
55
55
  ]
56
- # Thin dev-workflow group so `uv sync` / uv.lock keep working. Resolves the
57
- # SAME set as the aggregate `[ai]` extra below via a PEP 508 self-reference, so
58
- # there is a single source of truth (the granular extras) and no hand-sync.
59
- # `test_packaging_extras.py` asserts this stays consistent with the extra.
56
+ # Thin dev-workflow group so `uv sync` / uv.lock keep working. References the
57
+ # `[ai]` extra so dev installs every AI capability (single source of truth).
60
58
  ai = ["videopython[ai]"]
61
59
 
62
- # pip install videopython[<extra>] - pip uses optional-dependencies, not dependency-groups.
60
+ # pip install "videopython[ai]" - pip uses optional-dependencies, not dependency-groups.
63
61
  #
64
- # Granular extras partition the heavy ML deps by the leaf module that imports
65
- # them, so consumers install only what a capability needs. `ai` is a convenience
66
- # aggregate that installs EVERYTHING via PEP 685 self-references (no hand-sync).
67
- # `test_packaging_extras.py` enforces union(granular) == ai as a drift guard.
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.
68
65
  [project.optional-dependencies]
69
- # Speech recognition / diarization (understanding/audio.py).
70
- asr = [
66
+ ai = [
67
+ # Speech recognition / diarization (understanding/audio.py)
71
68
  "openai-whisper>=20240930",
72
69
  "pyannote-audio>=4.0.0",
73
- # Voice activity detection (used by AudioToText to gate Whisper language detection)
74
70
  "silero-vad>=5.1",
75
- "torch>=2.8.0",
76
- "torchaudio>=2.8.0",
77
- ]
78
- # Visual understanding: detection, scene/temporal, VLM (understanding/{faces,
79
- # objects,temporal,image}.py + video_analysis). transformers also backs the AST
80
- # AudioClassifier and the SceneVLM; qwen-vl-utils is the SceneVLM preprocessor;
81
- # imagehash is the SceneVLM frame-dedup; transnetv2 is scene detection.
82
- vision = [
71
+ # Visual understanding: detection, scene/temporal, VLM (understanding/*, video_analysis)
83
72
  "ultralytics>=8.0.0",
84
73
  "transnetv2-pytorch>=1.0.5",
85
74
  "transformers>=5.2.0",
86
75
  "qwen-vl-utils>=0.0.10",
87
76
  "imagehash>=4.3",
88
- "torch>=2.8.0",
89
- "torchaudio>=2.8.0",
90
- ]
91
- # Audio source separation (understanding/separation.py).
92
- separation = [
77
+ # Audio source separation (understanding/separation.py)
93
78
  "demucs>=4.0.0",
94
- "torch>=2.8.0",
95
- "torchaudio>=2.8.0",
96
- ]
97
- # Text translation backends (generation/translation.py MarianMT +
98
- # generation/qwen3.py Qwen3 GGUF). MarianTokenizer requires sentencepiece.
99
- translation = [
100
- "transformers>=5.2.0",
79
+ # Translation backends (generation/translation.py MarianMT, generation/qwen3.py GGUF)
101
80
  "sentencepiece>=0.1.99",
102
81
  "llama-cpp-python>=0.3.0",
103
- "torch>=2.8.0",
104
- ]
105
- # Voice cloning TTS (generation/audio.py — Chatterbox Multilingual by Resemble
106
- # AI). ISOLATED: chatterbox-tts 0.1.7 carries the strict pins that force the
107
- # [tool.uv].override-dependencies block. This is the ONLY extra that drags them.
108
- # Left to chatterbox's own torch==2.6.0 / torchaudio==2.6.0 pins (no floors
109
- # here) so `pip install videopython[tts]` resolves STANDALONE in its own
110
- # image/Modal function -- the whole point of running TTS behind SpeechBackend.
111
- # The all-in [ai] resolve forces torch>=2.8.0 over this via override-dependencies.
112
- tts = [
82
+ # Voice cloning TTS (generation/audio.py — Chatterbox); its strict pins drive
83
+ # the [tool.uv].override-dependencies block below.
113
84
  "chatterbox-tts>=0.1.7",
114
- ]
115
- # Local media generation: SDXL/CogVideoX (generation/{image,video}.py) +
116
- # MusicGen (generation/audio.py TextToMusic, via transformers).
117
- generation = [
85
+ # Local media generation: SDXL/CogVideoX + MusicGen (generation/*)
118
86
  "diffusers>=0.30.0",
119
- "transformers>=5.2.0",
120
87
  "accelerate>=0.29.2",
88
+ # Dubbing loudness matching (dubbing/loudness.py)
89
+ "pyloudnorm>=0.1.1",
90
+ # LLM-authored editing planner (auto_edit/local.py OllamaVisionLLM)
91
+ "ollama>=0.5.0",
92
+ # Shared torch stack
121
93
  "torch>=2.8.0",
122
94
  "torchaudio>=2.8.0",
123
95
  ]
124
- # Dubbing pipeline's hard top-level deps: transcription (asr) + source
125
- # separation (separation) + translation, plus BS.1770 loudness matching
126
- # (dubbing/loudness.py). Deliberately EXCLUDES tts/chatterbox: local synthesis
127
- # runs through the SpeechBackend protocol against a separately-installed [tts]
128
- # extra or an injected remote backend, so a [dub] image co-resolves WITHOUT
129
- # chatterbox's override-forcing pin. A bare [dub] install that hits local
130
- # synthesis raises a clear [tts]-pointing ImportError.
131
- dub = [
132
- "videopython[asr,separation,translation]",
133
- # Loudness measurement (BS.1770) for dub-vs-source loudness matching (M3)
134
- "pyloudnorm>=0.1.1",
135
- ]
136
- # Convenience aggregate: installs every capability. PEP 685 self-references make
137
- # this the single "everything" install with no hand-duplicated dep list. This
138
- # combined resolve is what the [tool.uv].override-dependencies block exists for.
139
- ai = ["videopython[asr,vision,separation,translation,tts,generation,dub]"]
140
96
 
141
97
  [project.urls]
142
98
  Homepage = "https://videopython.com"
@@ -172,16 +128,10 @@ module = [
172
128
  ignore_missing_imports = true
173
129
 
174
130
  [tool.uv]
175
- # LOAD-BEARING ONLY for the all-in `[ai]` resolve (every extra installed
176
- # together, as in dev/CI and `uv sync`). chatterbox-tts 0.1.7 (the `[tts]`
177
- # extra) pins strict torch/torchaudio/numpy/diffusers that conflict with
178
- # pyannote-audio (torch>=2.8, `[asr]`) and CogVideoX (diffusers>=0.30,
179
- # `[generation]`). This override lets the combined resolver pick compatible
180
- # versions. A consumer installing a SUBSET that excludes chatterbox — notably
181
- # `[dub]` (asr+separation+translation+pyloudnorm, no tts) — co-resolves cleanly
182
- # WITHOUT this override; that clean `[dub]` resolve is the whole point of
183
- # isolating `[tts]`. The ai dependency floors are aligned with these overrides
184
- # to keep pip and uv resolving similar versions.
131
+ # chatterbox-tts 0.1.7 (in [ai]) pins strict torch/torchaudio/numpy/diffusers
132
+ # that conflict with pyannote-audio (torch>=2.8) and CogVideoX (diffusers>=0.30).
133
+ # These overrides let the combined [ai] resolve pick compatible versions; the
134
+ # floors in [ai] are aligned with them so pip and uv resolve similar versions.
185
135
  override-dependencies = [
186
136
  "torch>=2.8.0", "torchaudio>=2.8.0", "numpy>=2.0.0", "diffusers>=0.30.0",
187
137
  # ultralytics depends on opencv-python which conflicts with our
@@ -24,6 +24,16 @@ if TYPE_CHECKING:
24
24
  # Redundant aliases mark these as intentional re-exports (mypy/IDE see the
25
25
  # symbols; ruff doesn't flag them as unused). Runtime resolution is lazy via
26
26
  # __getattr__ below.
27
+ from .auto_edit import AutoEditError as AutoEditError
28
+ from .auto_edit import AutoEditor as AutoEditor
29
+ from .auto_edit import EditCatalog as EditCatalog
30
+ from .auto_edit import EditPlan as EditPlan
31
+ from .auto_edit import ImagePart as ImagePart
32
+ from .auto_edit import OllamaVisionLLM as OllamaVisionLLM
33
+ from .auto_edit import PlannerError as PlannerError
34
+ from .auto_edit import StructuredVisionLLM as StructuredVisionLLM
35
+ from .auto_edit import TextPart as TextPart
36
+ from .auto_edit import build_catalog as build_catalog
27
37
  from .effects import ObjectDetectionOverlay as ObjectDetectionOverlay
28
38
  from .generation import ImageToVideo as ImageToVideo
29
39
  from .generation import TextToImage as TextToImage
@@ -66,6 +76,17 @@ _exports: dict[str, str] = {
66
76
  "VideoAnalysis": ".video_analysis",
67
77
  "VideoAnalysisConfig": ".video_analysis",
68
78
  "VideoAnalyzer": ".video_analysis",
79
+ # Auto-editing (LLM-authored edits)
80
+ "AutoEditor": ".auto_edit",
81
+ "AutoEditError": ".auto_edit",
82
+ "OllamaVisionLLM": ".auto_edit",
83
+ "StructuredVisionLLM": ".auto_edit",
84
+ "PlannerError": ".auto_edit",
85
+ "TextPart": ".auto_edit",
86
+ "ImagePart": ".auto_edit",
87
+ "EditCatalog": ".auto_edit",
88
+ "EditPlan": ".auto_edit",
89
+ "build_catalog": ".auto_edit",
69
90
  }
70
91
 
71
92
  __all__ = list(_exports)
@@ -0,0 +1,29 @@
1
+ """LLM-authored editing: build a scene catalog and plan a VideoEdit from it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .backend import ImagePart, Part, PlannerError, StructuredVisionLLM, TextPart
6
+ from .catalog import build_catalog
7
+ from .editor import AutoEditError, AutoEditor
8
+ from .local import OllamaVisionLLM
9
+ from .models import CatalogBundle, CatalogScene, EditCatalog, EditPlan, PlanSegment
10
+ from .resolve import UnknownSceneIdsError, resolve_plan
11
+
12
+ __all__ = [
13
+ "AutoEditError",
14
+ "AutoEditor",
15
+ "CatalogBundle",
16
+ "CatalogScene",
17
+ "EditCatalog",
18
+ "EditPlan",
19
+ "ImagePart",
20
+ "OllamaVisionLLM",
21
+ "Part",
22
+ "PlanSegment",
23
+ "PlannerError",
24
+ "StructuredVisionLLM",
25
+ "TextPart",
26
+ "UnknownSceneIdsError",
27
+ "build_catalog",
28
+ "resolve_plan",
29
+ ]
@@ -0,0 +1,37 @@
1
+ """The SDK-free seam between the editor and a structured-vision model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ import numpy as np
9
+
10
+
11
+ @dataclass
12
+ class TextPart:
13
+ """A text chunk in a planner prompt."""
14
+
15
+ text: str
16
+
17
+
18
+ @dataclass
19
+ class ImagePart:
20
+ """A keyframe in a planner prompt: an RGB (H, W, 3) uint8 array."""
21
+
22
+ image: np.ndarray
23
+ label: str | None = None
24
+
25
+
26
+ Part = TextPart | ImagePart
27
+
28
+
29
+ class PlannerError(RuntimeError):
30
+ """A backend produced unusable output; the editor retries (infra errors should propagate instead)."""
31
+
32
+
33
+ @runtime_checkable
34
+ class StructuredVisionLLM(Protocol):
35
+ """Returns schema-shaped JSON from interleaved text + images; raises PlannerError on bad output."""
36
+
37
+ def generate_json(self, *, system: str, parts: list[Part], schema: dict[str, Any]) -> dict[str, Any]: ...
@@ -0,0 +1,90 @@
1
+ """Build the LLM-facing edit catalog from VideoAnalysis results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+
11
+ from videopython.base.video import extract_frames_at_times
12
+
13
+ from .models import CatalogBundle, CatalogScene, EditCatalog
14
+
15
+ if TYPE_CHECKING:
16
+ from videopython.ai.video_analysis import SceneAnalysisSample, VideoAnalysis
17
+ from videopython.base.transcription import Transcription
18
+
19
+ DEFAULT_TRANSCRIPT_CHARS = 280
20
+
21
+
22
+ def build_catalog(
23
+ analyses: Sequence[VideoAnalysis],
24
+ *,
25
+ keyframes: bool = True,
26
+ max_transcript_chars: int = DEFAULT_TRANSCRIPT_CHARS,
27
+ ) -> CatalogBundle:
28
+ """Project VideoAnalysis results into a flat scene catalog with one midpoint keyframe per scene."""
29
+ scenes: list[CatalogScene] = []
30
+ frames: dict[str, np.ndarray] = {}
31
+ used_ids: set[str] = set()
32
+
33
+ for analysis in analyses:
34
+ source_path = analysis.source.path
35
+ samples = analysis.scenes.samples if analysis.scenes else []
36
+ transcription = analysis.audio.transcription if analysis.audio else None
37
+ stem = Path(source_path).stem if source_path else "clip"
38
+
39
+ for sample in samples:
40
+ scene_id = _unique_id(stem, sample.scene_index, used_ids)
41
+ caption, shot_type = _description(sample)
42
+ transcript = _transcript_excerpt(
43
+ transcription, sample.start_second, sample.end_second, max_transcript_chars
44
+ )
45
+ scenes.append(
46
+ CatalogScene(
47
+ id=scene_id,
48
+ source=Path(source_path) if source_path else Path(stem),
49
+ start=sample.start_second,
50
+ end=sample.end_second,
51
+ duration=max(0.0, sample.end_second - sample.start_second),
52
+ shot_type=shot_type,
53
+ caption=caption,
54
+ transcript=transcript,
55
+ has_speech=bool(transcript),
56
+ has_faces=bool(sample.faces),
57
+ )
58
+ )
59
+ if keyframes:
60
+ if source_path is None:
61
+ raise ValueError(f"Scene {scene_id!r} has no source path to extract a keyframe from.")
62
+ midpoint = (sample.start_second + sample.end_second) / 2.0
63
+ frames[scene_id] = extract_frames_at_times(source_path, [midpoint])[0]
64
+
65
+ return CatalogBundle(catalog=EditCatalog(scenes=scenes), keyframes=frames)
66
+
67
+
68
+ def _unique_id(stem: str, scene_index: int, used: set[str]) -> str:
69
+ base = f"{stem}#{scene_index}"
70
+ candidate, suffix = base, 2
71
+ while candidate in used:
72
+ candidate = f"{base}-{suffix}"
73
+ suffix += 1
74
+ used.add(candidate)
75
+ return candidate
76
+
77
+
78
+ def _description(sample: SceneAnalysisSample) -> tuple[str, str | None]:
79
+ desc = sample.scene_description
80
+ return (desc.caption or "", desc.shot_type) if desc else ("", None)
81
+
82
+
83
+ def _transcript_excerpt(transcription: Transcription | None, start: float, end: float, max_chars: int) -> str:
84
+ sliced = transcription.slice(start, end) if transcription else None
85
+ if sliced is None:
86
+ return ""
87
+ text = " ".join(" ".join(segment.text.split()) for segment in sliced.segments).strip()
88
+ if len(text) > max_chars:
89
+ text = text[: max_chars - 3].rstrip() + "..."
90
+ return text
@@ -0,0 +1,140 @@
1
+ """AutoEditor: turn source videos + a brief into a runnable VideoEdit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, Literal
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from .backend import ImagePart, Part, PlannerError, StructuredVisionLLM, TextPart
12
+ from .catalog import build_catalog
13
+ from .models import CatalogBundle, CatalogScene, EditPlan
14
+ from .resolve import UnknownSceneIdsError, resolve_plan
15
+
16
+ if TYPE_CHECKING:
17
+ from videopython.ai.video_analysis import VideoAnalysis, VideoAnalyzer
18
+ from videopython.base.video import VideoMetadata
19
+ from videopython.editing import VideoEdit
20
+
21
+ _SYSTEM_PROMPT = (
22
+ "You are a video editor. You are given a creative brief and a catalog of candidate scenes "
23
+ "from one or more source videos, each shown with its keyframe. Select and order scenes into "
24
+ "an edit that fulfils the brief. Reference scenes only by their catalog id via 'scene_id'; "
25
+ "never invent timestamps or sources."
26
+ )
27
+
28
+ _NormalizeTarget = tuple[int, int] | Literal["first", "largest", "match"]
29
+
30
+
31
+ class AutoEditError(RuntimeError):
32
+ """The planner could not produce a valid edit within the retry budget."""
33
+
34
+
35
+ class AutoEditor:
36
+ """Plan a VideoEdit from source videos using a structured-vision planner."""
37
+
38
+ def __init__(
39
+ self,
40
+ planner: StructuredVisionLLM,
41
+ *,
42
+ analyzer: VideoAnalyzer | None = None,
43
+ max_rounds: int = 3,
44
+ normalize_target: _NormalizeTarget = "largest",
45
+ ) -> None:
46
+ self.planner = planner
47
+ self._analyzer = analyzer
48
+ self.max_rounds = max_rounds
49
+ self.normalize_target = normalize_target
50
+
51
+ def edit(self, sources: Sequence[str | Path], brief: str, *, context: dict[str, Any] | None = None) -> VideoEdit:
52
+ """Analyze ``sources`` and plan an edit for ``brief`` (runs the analyzer)."""
53
+ analyses = [self._get_analyzer().analyze_path(source) for source in sources]
54
+ return self.edit_from_analyses(analyses, brief, context=context)
55
+
56
+ def edit_from_analyses(
57
+ self, analyses: Sequence[VideoAnalysis], brief: str, *, context: dict[str, Any] | None = None
58
+ ) -> VideoEdit:
59
+ """Plan an edit from precomputed VideoAnalysis results (no model download)."""
60
+ bundle = build_catalog(analyses)
61
+ metadata = _metadata_by_source(analyses)
62
+ run_context = _merge_context(analyses, context)
63
+ schema = EditPlan.json_schema(strict=True)
64
+ base_parts = _build_parts(brief, bundle)
65
+
66
+ feedback: str | None = None
67
+ for _ in range(self.max_rounds):
68
+ parts = base_parts if feedback is None else [*base_parts, TextPart(feedback)]
69
+ try:
70
+ raw = self.planner.generate_json(system=_SYSTEM_PROMPT, parts=parts, schema=schema)
71
+ edit = resolve_plan(EditPlan.model_validate(raw), bundle.catalog)
72
+ except (PlannerError, ValidationError, UnknownSceneIdsError) as exc:
73
+ feedback = _shape_feedback(exc)
74
+ continue
75
+ edit, _ = edit.repair(metadata, context=run_context, clamp_segment_end=True)
76
+ edit, _ = edit.normalize_dimensions(metadata, self.normalize_target, context=run_context)
77
+ errors = edit.check(metadata, context=run_context)
78
+ if not errors:
79
+ return edit
80
+ feedback = "The previous plan had these problems:\n" + "\n".join(e.to_prompt_line() for e in errors)
81
+
82
+ raise AutoEditError(f"No valid edit after {self.max_rounds} round(s). Last feedback:\n{feedback}")
83
+
84
+ def _get_analyzer(self) -> VideoAnalyzer:
85
+ if self._analyzer is None:
86
+ from videopython.ai.video_analysis import VideoAnalyzer
87
+
88
+ self._analyzer = VideoAnalyzer()
89
+ return self._analyzer
90
+
91
+
92
+ def _build_parts(brief: str, bundle: CatalogBundle) -> list[Part]:
93
+ parts: list[Part] = [TextPart(f"Brief: {brief}\n\nCandidate scenes:")]
94
+ for scene in bundle.catalog.scenes:
95
+ parts.append(TextPart(_scene_line(scene)))
96
+ frame = bundle.keyframes.get(scene.id)
97
+ if frame is not None:
98
+ parts.append(ImagePart(image=frame, label=scene.id))
99
+ return parts
100
+
101
+
102
+ def _scene_line(scene: CatalogScene) -> str:
103
+ bits = [f"[{scene.id}] {scene.duration:.1f}s"]
104
+ if scene.shot_type:
105
+ bits.append(scene.shot_type)
106
+ if scene.caption:
107
+ bits.append(scene.caption)
108
+ line = " | ".join(bits)
109
+ if scene.transcript:
110
+ line += f"\n speech: {scene.transcript}"
111
+ return line
112
+
113
+
114
+ def _metadata_by_source(analyses: Sequence[VideoAnalysis]) -> dict[str, VideoMetadata]:
115
+ from videopython.base.video import VideoMetadata
116
+
117
+ paths: set[str] = set()
118
+ for analysis in analyses:
119
+ if analysis.source.path is not None:
120
+ paths.add(analysis.source.path)
121
+ return {str(Path(p)): VideoMetadata.from_path(p) for p in paths}
122
+
123
+
124
+ def _merge_context(analyses: Sequence[VideoAnalysis], context: dict[str, Any] | None) -> dict[str, Any] | None:
125
+ transcriptions: dict[str, Any] = {}
126
+ for analysis in analyses:
127
+ src = analysis.source
128
+ if src.path is not None and analysis.audio is not None and analysis.audio.transcription is not None:
129
+ transcriptions[str(Path(src.path))] = analysis.audio.transcription
130
+ if not transcriptions:
131
+ return context
132
+ return {"transcription": transcriptions, **(context or {})}
133
+
134
+
135
+ def _shape_feedback(exc: PlannerError | ValidationError | UnknownSceneIdsError) -> str:
136
+ if isinstance(exc, UnknownSceneIdsError):
137
+ return f"Plan used scene ids not in the catalog: {sorted(set(exc.ids))}. Use only listed ids."
138
+ if isinstance(exc, PlannerError):
139
+ return f"Your previous response was not usable JSON ({exc}). Return one matching JSON object."
140
+ return f"The previous plan did not match the schema: {exc.errors()!r}"
@@ -0,0 +1,67 @@
1
+ """A local, model-agnostic planner backed by an Ollama server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import json
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+ from videopython.ai._optional import require
14
+
15
+ from .backend import ImagePart, Part, PlannerError, TextPart
16
+
17
+ DEFAULT_OLLAMA_MODEL = "llama3.2-vision"
18
+
19
+
20
+ class OllamaVisionLLM:
21
+ """A StructuredVisionLLM backed by a local Ollama server.
22
+
23
+ The model must be vision-capable (it is sent keyframes) AND support Ollama's
24
+ structured-output ``format`` (the EditPlan schema constrains the decode). Not
25
+ every model supports schema conditioning -- ``gemma3:27b`` is verified working;
26
+ some builds (e.g. certain MLX ones) fail it. ``ollama pull <model>`` first;
27
+ ``options`` are extra generation options merged over ``temperature=0``.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ model: str = DEFAULT_OLLAMA_MODEL,
33
+ *,
34
+ host: str | None = None,
35
+ options: dict[str, Any] | None = None,
36
+ ) -> None:
37
+ self.model = model
38
+ self.host = host
39
+ self.options: dict[str, Any] = {"temperature": 0.0, **(options or {})}
40
+ self._client: Any = None
41
+
42
+ def _get_client(self) -> Any:
43
+ if self._client is None:
44
+ ollama = require("ollama", "ai", feature="OllamaVisionLLM")
45
+ self._client = ollama.Client(host=self.host)
46
+ return self._client
47
+
48
+ def generate_json(self, *, system: str, parts: list[Part], schema: dict[str, Any]) -> dict[str, Any]:
49
+ text = "\n\n".join(part.text for part in parts if isinstance(part, TextPart))
50
+ images = [_encode_png_b64(part.image) for part in parts if isinstance(part, ImagePart)]
51
+ user: dict[str, Any] = {"role": "user", "content": text}
52
+ if images:
53
+ user["images"] = images
54
+ messages = [{"role": "system", "content": system}, user]
55
+ # Ollama's structured output: the schema constrains the decode to valid JSON.
56
+ response = self._get_client().chat(model=self.model, messages=messages, format=schema, options=self.options)
57
+ content = response.message.content
58
+ try:
59
+ return json.loads(content)
60
+ except (ValueError, TypeError) as exc:
61
+ raise PlannerError(f"Ollama returned non-JSON output: {content!r}") from exc
62
+
63
+
64
+ def _encode_png_b64(frame: np.ndarray) -> str:
65
+ buffer = io.BytesIO()
66
+ Image.fromarray(frame).save(buffer, format="PNG")
67
+ return base64.b64encode(buffer.getvalue()).decode("ascii")