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.
- {videopython-0.46.0 → videopython-0.48.0}/PKG-INFO +2 -45
- {videopython-0.46.0 → videopython-0.48.0}/README.md +0 -2
- {videopython-0.46.0 → videopython-0.48.0}/pyproject.toml +24 -74
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/__init__.py +21 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/__init__.py +29 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/backend.py +37 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/catalog.py +90 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/editor.py +140 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/local.py +67 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/models.py +105 -0
- videopython-0.48.0/src/videopython/ai/auto_edit/resolve.py +34 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/loudness.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/effects.py +9 -4
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/audio.py +2 -2
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/image.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/qwen3.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/translation.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/video.py +2 -4
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/transforms.py +0 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/audio.py +5 -5
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/faces.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/image.py +2 -2
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/objects.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/separation.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/temporal.py +1 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/sampling.py +1 -1
- videopython-0.48.0/src/videopython/editing/_schema.py +42 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/effects.py +0 -372
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/operation.py +30 -20
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/streaming.py +18 -26
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/transcription_overlay.py +0 -1
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/transforms.py +0 -6
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/video_edit.py +23 -68
- {videopython-0.46.0 → videopython-0.48.0}/.gitignore +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/LICENSE +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_optional.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_predictor.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/_revisions.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/description.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/exceptions.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/fonts/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/base/video.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/__init__.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/_ass.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.46.0 → videopython-0.48.0}/src/videopython/editing/audio_ops.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
57
|
-
#
|
|
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[
|
|
60
|
+
# pip install "videopython[ai]" - pip uses optional-dependencies, not dependency-groups.
|
|
63
61
|
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
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")
|