videopython 0.50.1__tar.gz → 0.52.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 (95) hide show
  1. {videopython-0.50.1 → videopython-0.52.0}/PKG-INFO +6 -4
  2. {videopython-0.50.1 → videopython-0.52.0}/README.md +5 -3
  3. {videopython-0.50.1 → videopython-0.52.0}/pyproject.toml +1 -1
  4. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/__init__.py +14 -12
  5. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/_ollama.py +5 -27
  6. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/_optional.py +13 -15
  7. videopython-0.52.0/src/videopython/ai/_predictor.py +70 -0
  8. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/_revisions.py +1 -1
  9. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/auto_edit/__init__.py +10 -5
  10. videopython-0.52.0/src/videopython/ai/auto_edit/backend.py +30 -0
  11. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/auto_edit/editor.py +16 -10
  12. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/auto_edit/local.py +12 -5
  13. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/auto_edit/resolve.py +2 -1
  14. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/__init__.py +0 -2
  15. {videopython-0.50.1/src/videopython/ai/generation → videopython-0.52.0/src/videopython/ai/dubbing}/_tts_backend.py +7 -8
  16. videopython-0.50.1/src/videopython/ai/dubbing/loudness.py → videopython-0.52.0/src/videopython/ai/dubbing/audio_ops.py +47 -2
  17. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/config.py +16 -7
  18. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/dubber.py +8 -9
  19. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/models.py +22 -3
  20. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/pipeline.py +19 -22
  21. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/quality.py +3 -1
  22. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/remux.py +8 -72
  23. {videopython-0.50.1/src/videopython/ai/understanding → videopython-0.52.0/src/videopython/ai/dubbing}/separation.py +4 -12
  24. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/timing.py +1 -62
  25. {videopython-0.50.1/src/videopython/ai/generation → videopython-0.52.0/src/videopython/ai/dubbing}/translation.py +1 -48
  26. videopython-0.52.0/src/videopython/ai/errors.py +13 -0
  27. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/generation/audio.py +5 -19
  28. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/generation/image.py +4 -7
  29. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/generation/video.py +7 -13
  30. videopython-0.52.0/src/videopython/ai/keyframe.py +43 -0
  31. videopython-0.52.0/src/videopython/ai/ops.py +23 -0
  32. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/transforms.py +2 -2
  33. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/understanding/__init__.py +9 -7
  34. videopython-0.52.0/src/videopython/ai/understanding/_yolo.py +111 -0
  35. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/understanding/audio.py +6 -201
  36. videopython-0.52.0/src/videopython/ai/understanding/classification.py +196 -0
  37. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/understanding/faces.py +95 -109
  38. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/understanding/objects.py +14 -68
  39. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/understanding/temporal.py +2 -7
  40. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/video_analysis/__init__.py +7 -1
  41. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/video_analysis/analyzer.py +41 -141
  42. videopython-0.50.1/src/videopython/ai/video_analysis/stages.py → videopython-0.52.0/src/videopython/ai/video_analysis/detectors.py +2 -2
  43. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/video_analysis/models.py +0 -5
  44. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/video_analysis/sampling.py +1 -1
  45. videopython-0.52.0/src/videopython/ai/video_analysis/source_metadata.py +133 -0
  46. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/mcp/server.py +2 -2
  47. videopython-0.50.1/src/videopython/ai/_predictor.py +0 -54
  48. videopython-0.50.1/src/videopython/ai/auto_edit/backend.py +0 -37
  49. videopython-0.50.1/src/videopython/ai/dubbing/expressiveness.py +0 -47
  50. {videopython-0.50.1 → videopython-0.52.0}/.gitignore +0 -0
  51. {videopython-0.50.1 → videopython-0.52.0}/LICENSE +0 -0
  52. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/__init__.py +0 -0
  53. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/_device.py +0 -0
  54. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/auto_edit/catalog.py +0 -0
  55. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/auto_edit/models.py +0 -0
  56. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  57. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/effects.py +0 -0
  58. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/generation/__init__.py +0 -0
  59. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/ai/understanding/image.py +0 -0
  60. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/audio/__init__.py +0 -0
  61. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/audio/analysis.py +0 -0
  62. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/audio/audio.py +0 -0
  63. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/__init__.py +0 -0
  64. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/_dimensions.py +0 -0
  65. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/_ffmpeg.py +0 -0
  66. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/_video_io.py +0 -0
  67. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/description.py +0 -0
  68. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/draw_detections.py +0 -0
  69. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/exceptions.py +0 -0
  70. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  71. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  72. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  73. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  74. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  75. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  76. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  77. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  78. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  79. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  80. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/fonts/__init__.py +0 -0
  81. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/transcription.py +0 -0
  82. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/base/video.py +0 -0
  83. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/__init__.py +0 -0
  84. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/_ass.py +0 -0
  85. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/_easing.py +0 -0
  86. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/_schema.py +0 -0
  87. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/audio_ops.py +0 -0
  88. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/effects.py +0 -0
  89. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/operation.py +0 -0
  90. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/streaming.py +0 -0
  91. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/transcription_overlay.py +0 -0
  92. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/transforms.py +0 -0
  93. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/editing/video_edit.py +0 -0
  94. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/mcp/__init__.py +0 -0
  95. {videopython-0.50.1 → videopython-0.52.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.50.1
3
+ Version: 0.52.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/
@@ -128,11 +128,13 @@ Putting an LLM in the loop works three ways:
128
128
  2. **`AutoEditor`** — a local Ollama vision model is the planner (see [Automatic editing](#automatic-editing-local-llm) above).
129
129
  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/).
130
130
 
131
- For mode 1: every operation is a Pydantic model whose fields ARE the JSON wire format. `VideoEdit.json_schema()` returns a JSON Schema with a discriminated union over every LLM-exposed `Operation` (server-only ops like `image_overlay` are excluded by default) pass it straight to Anthropic tool use, OpenAI function calling, or any structured-output API. Pass `strict=True` for a provider strict-mode grammar that prevents simple bound violations at decode time.
131
+ **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:
132
132
 
133
- The plan parses permissively (shape only) and owns numeric bounds at validation, so a refine loop converges fast: `edit.check(meta)` collects **every** structured `PlanError` in one pass, `edit.repair(meta)` auto-clamps the mechanical violations (window/timestamp overruns, negatives) with a reported changelog, and `edit.normalize_dimensions(meta, target)` makes heterogeneous segments concat-compatible by construction. `edit.validate()` still raises a typed `PlanValidationError` (a `ValueError` with structured `.errors`) for the single-error path.
133
+ - **`edit.check(meta)`** collect *every* structured error in one pass, not just the first
134
+ - **`edit.repair(meta)`** — auto-clamp mechanical violations (overruns, negatives) with a changelog
135
+ - **`edit.normalize_dimensions(meta, target)`** — make heterogeneous segments concat-compatible
134
136
 
135
- See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/) for end-to-end examples, the collect/repair/normalize refine loop, and operation discovery patterns.
137
+ 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.
136
138
 
137
139
  ## Features
138
140
 
@@ -84,11 +84,13 @@ Putting an LLM in the loop works three ways:
84
84
  2. **`AutoEditor`** — a local Ollama vision model is the planner (see [Automatic editing](#automatic-editing-local-llm) above).
85
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/).
86
86
 
87
- For mode 1: every operation is a Pydantic model whose fields ARE the JSON wire format. `VideoEdit.json_schema()` returns a JSON Schema with a discriminated union over every LLM-exposed `Operation` (server-only ops like `image_overlay` are excluded by default) pass it straight to Anthropic tool use, OpenAI function calling, or any structured-output API. Pass `strict=True` for a provider strict-mode grammar that prevents simple bound violations at decode time.
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:
88
88
 
89
- The plan parses permissively (shape only) and owns numeric bounds at validation, so a refine loop converges fast: `edit.check(meta)` collects **every** structured `PlanError` in one pass, `edit.repair(meta)` auto-clamps the mechanical violations (window/timestamp overruns, negatives) with a reported changelog, and `edit.normalize_dimensions(meta, target)` makes heterogeneous segments concat-compatible by construction. `edit.validate()` still raises a typed `PlanValidationError` (a `ValueError` with structured `.errors`) for the single-error path.
89
+ - **`edit.check(meta)`** collect *every* structured error in one pass, not just the first
90
+ - **`edit.repair(meta)`** — auto-clamp mechanical violations (overruns, negatives) with a changelog
91
+ - **`edit.normalize_dimensions(meta, target)`** — make heterogeneous segments concat-compatible
90
92
 
91
- See the [LLM Integration Guide](https://videopython.com/guides/llm-integration/) for end-to-end examples, the collect/repair/normalize refine loop, and operation discovery patterns.
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.
92
94
 
93
95
  ## Features
94
96
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.50.1"
3
+ version = "0.52.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -2,12 +2,13 @@
2
2
 
3
3
  Every public symbol is re-exported lazily via PEP 562 ``__getattr__`` so that
4
4
  ``import videopython.ai`` (or ``from videopython.ai import X``) loads ONLY the
5
- leaf module backing the requested symbol — not every sibling. This is what
6
- makes the granular extras (``asr``, ``vision``, ``tts``, ``generation``,
7
- ``dub``, ...) usable: with only ``[asr]`` installed, ``from videopython.ai
8
- import AudioToText`` works, while touching ``TextToSpeech`` raises a clear
9
- ``[tts]``-pointing ``ImportError`` at attribute access instead of failing the
10
- whole package import.
5
+ leaf module backing the requested symbol — not every sibling. All AI capabilities
6
+ ship in the single ``[ai]`` extra; the laziness keeps ``import videopython`` (and
7
+ importing one leaf class) light by deferring the heavy ML imports (torch /
8
+ transformers / diffusers / ultralytics) until a symbol is actually used. When
9
+ ``[ai]`` is not installed, touching a symbol raises a clear
10
+ ``pip install 'videopython[ai]'``-pointing ``ImportError`` at attribute access
11
+ instead of failing the whole package import.
11
12
 
12
13
  The ``TYPE_CHECKING`` block below keeps the eager imports visible to mypy and
13
14
  IDEs (static autocompletion / re-export checking) without executing them at
@@ -28,13 +29,12 @@ if TYPE_CHECKING:
28
29
  from .auto_edit import AutoEditor as AutoEditor
29
30
  from .auto_edit import EditCatalog as EditCatalog
30
31
  from .auto_edit import EditPlan as EditPlan
31
- from .auto_edit import ImagePart as ImagePart
32
32
  from .auto_edit import OllamaVisionLLM as OllamaVisionLLM
33
33
  from .auto_edit import PlannerError as PlannerError
34
34
  from .auto_edit import StructuredVisionLLM as StructuredVisionLLM
35
- from .auto_edit import TextPart as TextPart
36
35
  from .auto_edit import build_catalog as build_catalog
37
36
  from .effects import ObjectDetectionOverlay as ObjectDetectionOverlay
37
+ from .errors import AiError as AiError
38
38
  from .generation import ImageToVideo as ImageToVideo
39
39
  from .generation import TextToImage as TextToImage
40
40
  from .generation import TextToMusic as TextToMusic
@@ -43,7 +43,8 @@ if TYPE_CHECKING:
43
43
  from .transforms import FaceTrackingCrop as FaceTrackingCrop
44
44
  from .understanding import AudioClassifier as AudioClassifier
45
45
  from .understanding import AudioToText as AudioToText
46
- from .understanding import FaceTracker as FaceTracker
46
+ from .understanding import FaceShotTracker as FaceShotTracker
47
+ from .understanding import FaceSmoothingTracker as FaceSmoothingTracker
47
48
  from .understanding import ObjectDetector as ObjectDetector
48
49
  from .understanding import SceneVLM as SceneVLM
49
50
  from .understanding import SemanticSceneDetector as SemanticSceneDetector
@@ -64,7 +65,8 @@ _exports: dict[str, str] = {
64
65
  # Understanding
65
66
  "AudioToText": ".understanding",
66
67
  "AudioClassifier": ".understanding",
67
- "FaceTracker": ".understanding",
68
+ "FaceShotTracker": ".understanding",
69
+ "FaceSmoothingTracker": ".understanding",
68
70
  "ObjectDetector": ".understanding",
69
71
  "SceneVLM": ".understanding",
70
72
  "SemanticSceneDetector": ".understanding",
@@ -76,14 +78,14 @@ _exports: dict[str, str] = {
76
78
  "VideoAnalysis": ".video_analysis",
77
79
  "VideoAnalysisConfig": ".video_analysis",
78
80
  "VideoAnalyzer": ".video_analysis",
81
+ # Errors
82
+ "AiError": ".errors",
79
83
  # Auto-editing (LLM-authored edits)
80
84
  "AutoEditor": ".auto_edit",
81
85
  "AutoEditError": ".auto_edit",
82
86
  "OllamaVisionLLM": ".auto_edit",
83
87
  "StructuredVisionLLM": ".auto_edit",
84
88
  "PlannerError": ".auto_edit",
85
- "TextPart": ".auto_edit",
86
- "ImagePart": ".auto_edit",
87
89
  "EditCatalog": ".auto_edit",
88
90
  "EditPlan": ".auto_edit",
89
91
  "build_catalog": ".auto_edit",
@@ -2,19 +2,17 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import base64
6
- import io
7
5
  import json
8
6
  from typing import Any
9
7
 
10
- import cv2
11
8
  import numpy as np
12
- from PIL import Image
13
9
 
14
10
  from videopython.ai._optional import require
11
+ from videopython.ai.errors import AiError
12
+ from videopython.ai.keyframe import encode_png_b64
15
13
 
16
14
 
17
- class OllamaError(RuntimeError):
15
+ class OllamaError(AiError, RuntimeError):
18
16
  """Ollama returned unusable output (non-JSON or an unexpected shape)."""
19
17
 
20
18
 
@@ -35,7 +33,7 @@ class OllamaStructuredClient:
35
33
 
36
34
  def _get_client(self) -> Any:
37
35
  if self._client is None:
38
- ollama = require("ollama", "ai", feature="Ollama")
36
+ ollama = require("ollama", feature="Ollama")
39
37
  self._client = ollama.Client(host=self.host)
40
38
  return self._client
41
39
 
@@ -50,7 +48,7 @@ class OllamaStructuredClient:
50
48
  """Return the parsed JSON object Ollama generates under ``schema``."""
51
49
  user: dict[str, Any] = {"role": "user", "content": text}
52
50
  if images:
53
- user["images"] = [_encode_png_b64(image) for image in images]
51
+ user["images"] = [encode_png_b64(image) for image in images]
54
52
  messages = [{"role": "system", "content": system}, user]
55
53
  response = self._get_client().chat(model=self.model, messages=messages, format=schema, options=self.options)
56
54
  content = response.message.content
@@ -64,23 +62,3 @@ class OllamaStructuredClient:
64
62
 
65
63
  def unload(self) -> None:
66
64
  self._client = None
67
-
68
-
69
- # Used only on the MCP keyframe path (videopython.mcp). SceneVLM captioning and the local
70
- # planner deliberately encode full-resolution frames via _encode_png_b64.
71
- KEYFRAME_MAX_DIM = 768 # bound a keyframe's longest side before PNG-encoding for the MCP payload
72
-
73
-
74
- def _downscale(frame: np.ndarray, max_dim: int = KEYFRAME_MAX_DIM) -> np.ndarray:
75
- """Shrink an RGB frame so its longest side is at most ``max_dim`` (aspect preserved; never upscales)."""
76
- h, w = frame.shape[:2]
77
- scale = max_dim / max(h, w)
78
- if scale >= 1.0:
79
- return frame
80
- return cv2.resize(frame, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA)
81
-
82
-
83
- def _encode_png_b64(frame: np.ndarray) -> str:
84
- buffer = io.BytesIO()
85
- Image.fromarray(frame).save(buffer, format="PNG")
86
- return base64.b64encode(buffer.getvalue()).decode("ascii")
@@ -6,12 +6,11 @@ method that needs it (there are no top-level ``import torch``/``transformers``/
6
6
  importable on a slim install: importing ``ai.understanding.objects`` only needs
7
7
  core deps until a detector is actually constructed.
8
8
 
9
- The granular extras (``asr``, ``vision``, ``tts``, ...) partition those heavy
10
- deps. When a user installs only one extra and reaches a code path needing a dep
11
- from another, the bare ``import`` would raise a stock ``ModuleNotFoundError``
12
- with no hint about which extra restores it. :func:`require` wraps the import and
13
- turns that into an actionable ``ImportError`` pointing at the right
14
- ``pip install 'videopython[<extra>]'``.
9
+ All those heavy deps ship in the single ``[ai]`` extra. When it isn't installed
10
+ and a code path reaches one of them, the bare ``import`` would raise a stock
11
+ ``ModuleNotFoundError`` with no hint about how to fix it. :func:`require` wraps
12
+ the import and turns that into an actionable ``ImportError`` pointing at
13
+ ``pip install 'videopython[ai]'``.
15
14
  """
16
15
 
17
16
  from __future__ import annotations
@@ -21,15 +20,13 @@ from types import ModuleType
21
20
  from typing import Callable
22
21
 
23
22
 
24
- def require(module: str, extra: str, *, feature: str | None = None) -> ModuleType:
25
- """Import ``module`` or raise a clear, extra-pointing ``ImportError``.
23
+ def require(module: str, *, feature: str | None = None) -> ModuleType:
24
+ """Import ``module`` or raise a clear, ``[ai]``-pointing ``ImportError``.
26
25
 
27
26
  Args:
28
27
  module: Dotted module name to import (e.g. ``"chatterbox.mtl_tts"``,
29
28
  ``"torch"``). The fully-imported module object is returned so the
30
29
  call site can bind the symbols it needs.
31
- extra: The videopython extra that ships ``module`` (e.g. ``"tts"``,
32
- ``"asr"``). Surfaced verbatim in the install hint.
33
30
  feature: Optional human-readable feature name for the message head. When
34
31
  omitted the top-level package of ``module`` is used.
35
32
 
@@ -38,14 +35,14 @@ def require(module: str, extra: str, *, feature: str | None = None) -> ModuleTyp
38
35
 
39
36
  Raises:
40
37
  ImportError: If ``module`` cannot be imported. The message always
41
- contains the extra name and a ``pip install 'videopython[<extra>]'``
42
- hint so the caller knows exactly how to fix it.
38
+ contains a ``pip install 'videopython[ai]'`` hint so the caller
39
+ knows exactly how to fix it.
43
40
  """
44
41
  try:
45
42
  return importlib.import_module(module)
46
43
  except ImportError as exc:
47
44
  label = feature or module.split(".")[0]
48
- raise ImportError(f"{label} requires the '{extra}' extra: pip install 'videopython[{extra}]'") from exc
45
+ raise ImportError(f"{label} requires the 'ai' extra: pip install 'videopython[ai]'") from exc
49
46
 
50
47
 
51
48
  def lazy_exports(package: str, exports: dict[str, str]) -> tuple[Callable[[str], object], Callable[[], list[str]]]:
@@ -53,8 +50,9 @@ def lazy_exports(package: str, exports: dict[str, str]) -> tuple[Callable[[str],
53
50
 
54
51
  Re-exports a set of public symbols lazily: the submodule backing a symbol is
55
52
  imported only on first attribute access, so importing the package does not
56
- pull in any sibling leaf module (this is what keeps the granular ``ai``
57
- extras independently installable).
53
+ pull in any sibling leaf module. This keeps ``import videopython`` (and
54
+ importing a single leaf class) light by deferring the heavy ML imports
55
+ (torch / transformers / diffusers / ultralytics) until a symbol is used.
58
56
 
59
57
  Args:
60
58
  package: The ``__name__`` of the package defining the re-exports. Used
@@ -0,0 +1,70 @@
1
+ """Context-manager base with a default VRAM-releasing ``unload()``.
2
+
3
+ Every predictor under ``videopython.ai`` holds a lazily-loaded model (and
4
+ sometimes a processor / pipeline / VAD field) on the selected device, and needs
5
+ to drop those references and free the allocator cache when done. That teardown
6
+ is mechanical and identical across predictors: set the model field(s) to
7
+ ``None`` and call :func:`videopython.ai._device.release_device_memory`.
8
+
9
+ :class:`ManagedPredictor` provides that as a default ``unload()`` driven by two
10
+ class attributes -- ``_model_attrs`` (the fields holding model state) and
11
+ ``_device_attr`` (the field holding the device) -- and turns any predictor into
12
+ a context manager::
13
+
14
+ with SceneVLM(...) as vlm:
15
+ ... # use vlm
16
+ # vlm.unload() has fired here, releasing VRAM
17
+
18
+ Subclasses with non-default model fields just declare ``_model_attrs``; those
19
+ whose teardown isn't "null the fields + release" (e.g. delegating to a client's
20
+ own ``unload()``) override ``unload()`` instead.
21
+
22
+ The base imports no torch / transformers / ultralytics (``release_device_memory``
23
+ defers its torch import), so it stays safe to mix into any predictor regardless
24
+ of how it is constructed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from types import TracebackType
30
+ from typing import TYPE_CHECKING, Literal
31
+
32
+ from videopython.ai._device import release_device_memory
33
+
34
+ if TYPE_CHECKING:
35
+ from typing_extensions import Self
36
+
37
+
38
+ class ManagedPredictor:
39
+ """Adds a default ``unload()`` plus ``with``-statement support.
40
+
41
+ ``unload()`` clears each attribute named in ``_model_attrs`` to ``None`` and
42
+ releases the cache for the device named by ``_device_attr``. It is idempotent
43
+ (safe before the model is loaded and on repeated calls). ``__exit__`` always
44
+ returns ``False`` so exceptions inside the ``with`` block propagate --
45
+ ``unload()`` runs on both the success and failure paths.
46
+ """
47
+
48
+ # Attributes holding model state, cleared to None on unload. Override per
49
+ # predictor (e.g. ("_model", "_processor")).
50
+ _model_attrs: tuple[str, ...] = ("_model",)
51
+ # Attribute holding the resolved device passed to release_device_memory.
52
+ _device_attr: str = "device"
53
+
54
+ def unload(self) -> None:
55
+ """Drop the model reference(s) and release device memory. Idempotent."""
56
+ for attr in self._model_attrs:
57
+ setattr(self, attr, None)
58
+ release_device_memory(getattr(self, self._device_attr, None))
59
+
60
+ def __enter__(self) -> Self:
61
+ return self
62
+
63
+ def __exit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc: BaseException | None,
67
+ tb: TracebackType | None,
68
+ ) -> Literal[False]:
69
+ self.unload()
70
+ return False
@@ -65,7 +65,7 @@ MODEL_REVISIONS: dict[str, str] = {
65
65
  # Speaker diarization (ai/understanding/audio.py: PYANNOTE_DIARIZATION_MODEL).
66
66
  # Gated repo (auto-approved); from_pretrained needs an accepted HF token.
67
67
  "pyannote/speaker-diarization-community-1": "3533c8cf8e369892e6b79ff1bf80f7b0286a54ee",
68
- # Audio event classifier (ai/understanding/audio.py: AudioClassifier)
68
+ # Audio event classifier (ai/understanding/classification.py: AudioClassifier)
69
69
  "MIT/ast-finetuned-audioset-10-10-0.4593": "f826b80d28226b62986cc218e5cec390b1096902",
70
70
  # Face detection checkpoint (ai/understanding/faces.py: hf_hub_download)
71
71
  "arnabdhar/YOLOv8-Face-Detection": "52fa54977207fa4f021de949b515fb19dcab4488",
@@ -1,8 +1,16 @@
1
- """LLM-authored editing: build a scene catalog and plan a VideoEdit from it."""
1
+ """LLM-authored editing: build a scene catalog and plan a VideoEdit from it.
2
+
3
+ Uses eager imports (unlike the leaf packages): it is a thin orchestration
4
+ package whose submodules load together, and it MUST eagerly import
5
+ ``videopython.ai.ops`` so the AI editing ops are registered before any
6
+ ``EditPlan.json_schema()`` call. Heavy ML deps it pulls in stay lazy.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
- from .backend import ImagePart, Part, PlannerError, StructuredVisionLLM, TextPart
11
+ import videopython.ai.ops # noqa: F401 -- registers the AI ops (face_crop, object_detection_overlay)
12
+
13
+ from .backend import PlannerError, StructuredVisionLLM
6
14
  from .catalog import build_catalog
7
15
  from .editor import AutoEditError, AutoEditor
8
16
  from .local import OllamaVisionLLM
@@ -16,13 +24,10 @@ __all__ = [
16
24
  "CatalogScene",
17
25
  "EditCatalog",
18
26
  "EditPlan",
19
- "ImagePart",
20
27
  "OllamaVisionLLM",
21
- "Part",
22
28
  "PlanSegment",
23
29
  "PlannerError",
24
30
  "StructuredVisionLLM",
25
- "TextPart",
26
31
  "UnknownSceneIdsError",
27
32
  "build_catalog",
28
33
  "resolve_plan",
@@ -0,0 +1,30 @@
1
+ """The SDK-free seam between the editor and a structured-vision model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
6
+
7
+ from videopython.ai.errors import AiError
8
+
9
+ if TYPE_CHECKING:
10
+ import numpy as np
11
+
12
+
13
+ class PlannerError(AiError, RuntimeError):
14
+ """A backend produced unusable output; the editor retries (infra errors should propagate instead)."""
15
+
16
+
17
+ @runtime_checkable
18
+ class StructuredVisionLLM(Protocol):
19
+ """Returns schema-shaped JSON from a system prompt + text + optional keyframes.
20
+
21
+ The signature mirrors
22
+ :meth:`videopython.ai._ollama.OllamaStructuredClient.generate_json`, so any
23
+ structured-generation client satisfies it structurally. Implementations
24
+ raise :class:`PlannerError` on unusable output (the editor retries those);
25
+ infra errors should propagate so they are not silently retried.
26
+ """
27
+
28
+ def generate_json(
29
+ self, *, system: str, text: str, images: list[np.ndarray] | None, schema: dict[str, Any]
30
+ ) -> dict[str, Any]: ...
@@ -8,12 +8,16 @@ from typing import TYPE_CHECKING, Any, Literal
8
8
 
9
9
  from pydantic import ValidationError
10
10
 
11
- from .backend import ImagePart, Part, PlannerError, StructuredVisionLLM, TextPart
11
+ from videopython.ai.errors import AiError
12
+
13
+ from .backend import PlannerError, StructuredVisionLLM
12
14
  from .catalog import build_catalog
13
15
  from .models import CatalogBundle, CatalogScene, EditPlan
14
16
  from .resolve import UnknownSceneIdsError, resolve_plan
15
17
 
16
18
  if TYPE_CHECKING:
19
+ import numpy as np
20
+
17
21
  from videopython.ai.video_analysis import VideoAnalysis, VideoAnalyzer
18
22
  from videopython.base.video import VideoMetadata
19
23
  from videopython.editing import VideoEdit
@@ -28,7 +32,7 @@ _SYSTEM_PROMPT = (
28
32
  _NormalizeTarget = tuple[int, int] | Literal["first", "largest", "match"]
29
33
 
30
34
 
31
- class AutoEditError(RuntimeError):
35
+ class AutoEditError(AiError, RuntimeError):
32
36
  """The planner could not produce a valid edit within the retry budget."""
33
37
 
34
38
 
@@ -61,13 +65,13 @@ class AutoEditor:
61
65
  metadata = _metadata_by_source(analyses)
62
66
  run_context = _merge_context(analyses, context)
63
67
  schema = EditPlan.json_schema(strict=True)
64
- base_parts = _build_parts(brief, bundle)
68
+ base_text, images = _build_prompt(brief, bundle)
65
69
 
66
70
  feedback: str | None = None
67
71
  for _ in range(self.max_rounds):
68
- parts = base_parts if feedback is None else [*base_parts, TextPart(feedback)]
72
+ text = base_text if feedback is None else f"{base_text}\n\n{feedback}"
69
73
  try:
70
- raw = self.planner.generate_json(system=_SYSTEM_PROMPT, parts=parts, schema=schema)
74
+ raw = self.planner.generate_json(system=_SYSTEM_PROMPT, text=text, images=images or None, schema=schema)
71
75
  edit = resolve_plan(EditPlan.model_validate(raw), bundle.catalog)
72
76
  except (PlannerError, ValidationError, UnknownSceneIdsError) as exc:
73
77
  feedback = _shape_feedback(exc)
@@ -89,14 +93,16 @@ class AutoEditor:
89
93
  return self._analyzer
90
94
 
91
95
 
92
- def _build_parts(brief: str, bundle: CatalogBundle) -> list[Part]:
93
- parts: list[Part] = [TextPart(f"Brief: {brief}\n\nCandidate scenes:")]
96
+ def _build_prompt(brief: str, bundle: CatalogBundle) -> tuple[str, list[np.ndarray]]:
97
+ """The planner prompt: scene-line text plus the keyframes, in catalog order."""
98
+ lines = [f"Brief: {brief}\n\nCandidate scenes:"]
99
+ images: list[np.ndarray] = []
94
100
  for scene in bundle.catalog.scenes:
95
- parts.append(TextPart(_scene_line(scene)))
101
+ lines.append(_scene_line(scene))
96
102
  frame = bundle.keyframes.get(scene.id)
97
103
  if frame is not None:
98
- parts.append(ImagePart(image=frame, label=scene.id))
99
- return parts
104
+ images.append(frame)
105
+ return "\n\n".join(lines), images
100
106
 
101
107
 
102
108
  def _scene_line(scene: CatalogScene) -> str:
@@ -2,11 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from videopython.ai._ollama import OllamaError, OllamaStructuredClient
8
8
 
9
- from .backend import ImagePart, Part, PlannerError, TextPart
9
+ from .backend import PlannerError
10
+
11
+ if TYPE_CHECKING:
12
+ import numpy as np
10
13
 
11
14
  DEFAULT_OLLAMA_MODEL = "gemma3:27b"
12
15
 
@@ -19,6 +22,10 @@ class OllamaVisionLLM:
19
22
  every model supports schema conditioning -- ``gemma3:27b`` is verified working;
20
23
  some builds (e.g. certain MLX ones) fail it. ``ollama pull <model>`` first;
21
24
  ``options`` are extra generation options merged over ``temperature=0``.
25
+
26
+ Thin wrapper over the shared :class:`OllamaStructuredClient`: its only job is
27
+ to translate :class:`OllamaError` into the :class:`PlannerError` the editor
28
+ retries on.
22
29
  """
23
30
 
24
31
  def __init__(
@@ -30,9 +37,9 @@ class OllamaVisionLLM:
30
37
  ) -> None:
31
38
  self._client = OllamaStructuredClient(model=model, host=host, options=options)
32
39
 
33
- def generate_json(self, *, system: str, parts: list[Part], schema: dict[str, Any]) -> dict[str, Any]:
34
- text = "\n\n".join(part.text for part in parts if isinstance(part, TextPart))
35
- images = [part.image for part in parts if isinstance(part, ImagePart)]
40
+ def generate_json(
41
+ self, *, system: str, text: str, images: list[np.ndarray] | None, schema: dict[str, Any]
42
+ ) -> dict[str, Any]:
36
43
  try:
37
44
  return self._client.generate_json(system=system, text=text, schema=schema, images=images or None)
38
45
  except OllamaError as exc:
@@ -2,12 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from videopython.ai.errors import AiError
5
6
  from videopython.editing import SegmentConfig, VideoEdit
6
7
 
7
8
  from .models import EditCatalog, EditPlan
8
9
 
9
10
 
10
- class UnknownSceneIdsError(ValueError):
11
+ class UnknownSceneIdsError(AiError, ValueError):
11
12
  """An EditPlan referenced scene ids absent from the catalog."""
12
13
 
13
14
  def __init__(self, ids: list[str]) -> None:
@@ -29,7 +29,6 @@ if TYPE_CHECKING:
29
29
  from videopython.ai.dubbing.quality import TranscriptQuality as TranscriptQuality
30
30
  from videopython.ai.dubbing.quality import assess_transcript as assess_transcript
31
31
  from videopython.ai.dubbing.timing import TimingSynchronizer as TimingSynchronizer
32
- from videopython.ai.generation.translation import UnsupportedLanguageError as UnsupportedLanguageError
33
32
 
34
33
  # Public symbol -> fully-qualified module that defines it.
35
34
  _exports: dict[str, str] = {
@@ -45,7 +44,6 @@ _exports: dict[str, str] = {
45
44
  "GarbageTranscriptError": "videopython.ai.dubbing.quality",
46
45
  "TranscriptQuality": "videopython.ai.dubbing.quality",
47
46
  "assess_transcript": "videopython.ai.dubbing.quality",
48
- "UnsupportedLanguageError": "videopython.ai.generation.translation",
49
47
  }
50
48
 
51
49
  __all__ = list(_exports)
@@ -1,18 +1,17 @@
1
1
  """Pluggable speech-synthesis backend protocol for the dubbing pipeline.
2
2
 
3
- Mirrors :class:`videopython.ai.generation.translation.TranslationBackend`: a
4
- dependency-free, ``runtime_checkable`` Protocol that the dubbing pipeline
3
+ A dependency-free, ``runtime_checkable`` Protocol that the dubbing pipeline
5
4
  depends on instead of binding directly to the local
6
5
  :class:`videopython.ai.generation.audio.TextToSpeech` implementation.
7
6
 
8
- This is the seam that lets dubbing run WITHOUT chatterbox in the process. The
9
- local ``TextToSpeech`` (which pulls ``chatterbox-tts`` via the ``[tts]`` extra)
7
+ This is the seam that lets a consumer run speech synthesis out-of-process. The
8
+ local ``TextToSpeech`` (which pulls ``chatterbox-tts`` from the ``[ai]`` extra)
10
9
  satisfies this protocol structurally — no changes needed there. A consumer that
11
- can't or won't install chatterbox (e.g. a service running synthesis in a
10
+ can't or won't load chatterbox in-process (e.g. a service running synthesis in a
12
11
  separate process or on a remote/Modal function) supplies its own object
13
- implementing :meth:`SpeechBackend.generate_audio` and injects it; the pipeline never
14
- imports chatterbox in that case. videopython ships ONLY this protocol plus the
15
- local backend — no reference remote/HTTP backend.
12
+ implementing :meth:`SpeechBackend.generate_audio` and injects it; the pipeline
13
+ never imports chatterbox in that case. videopython ships ONLY this protocol plus
14
+ the local backend — no reference remote/HTTP backend.
16
15
  """
17
16
 
18
17
  from __future__ import annotations
@@ -1,4 +1,11 @@
1
- """LUFS / peak loudness matching for dubbed audio."""
1
+ """Audio DSP helpers for the dubbing pipeline.
2
+
3
+ Merges the former ``expressiveness`` and ``loudness`` leaf modules: RMS,
4
+ source-prosody -> Chatterbox expressiveness mapping, and LUFS/peak loudness
5
+ matching. These are the small numpy/DSP helpers the pipeline composes; keeping
6
+ them in one file (rather than two ~50-line files) makes the audio-side toolkit
7
+ legible.
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
@@ -6,10 +13,48 @@ from typing import TYPE_CHECKING
6
13
 
7
14
  import numpy as np
8
15
 
16
+ from videopython.ai.dubbing.models import Expressiveness
17
+
9
18
  if TYPE_CHECKING:
10
19
  from videopython.audio import Audio
11
20
 
12
21
 
22
+ def rms(data: np.ndarray) -> float:
23
+ """RMS over samples; ``0.0`` for empty input. float64 reduction so a
24
+ long slice can't overflow the squared accumulator."""
25
+ if data.size == 0:
26
+ return 0.0
27
+ return float(np.sqrt(np.mean(np.square(data, dtype=np.float64))))
28
+
29
+
30
+ # --- Source-prosody-driven expressiveness (Chatterbox TTS knobs) -----------
31
+ # Source-segment RMS / whole-vocals RMS below CALM lands in the calm bucket;
32
+ # above DRAMATIC in the dramatic bucket; in between gets Chatterbox's defaults.
33
+ # Knob values picked by-ear on cam1_1min.mp4 -- see RELEASE_NOTES 0.29.0.
34
+ CALM_RATIO_THRESHOLD = 0.7
35
+ DRAMATIC_RATIO_THRESHOLD = 1.3
36
+ _CALM = Expressiveness(exaggeration=0.3, cfg_weight=0.7)
37
+ _DRAMATIC = Expressiveness(exaggeration=0.85, cfg_weight=0.35)
38
+
39
+
40
+ def expressiveness_for(source_slice: Audio, baseline_rms: float) -> Expressiveness:
41
+ """Map a source vocals slice to a Chatterbox expressiveness profile
42
+ by RMS ratio. Falls back to the no-knobs default for empty or silent
43
+ inputs."""
44
+ if baseline_rms <= 0.0:
45
+ return Expressiveness()
46
+ segment_rms = rms(source_slice.data)
47
+ if segment_rms <= 0.0:
48
+ return Expressiveness()
49
+ ratio = segment_rms / baseline_rms
50
+ if ratio < CALM_RATIO_THRESHOLD:
51
+ return _CALM
52
+ if ratio > DRAMATIC_RATIO_THRESHOLD:
53
+ return _DRAMATIC
54
+ return Expressiveness()
55
+
56
+
57
+ # --- LUFS / peak loudness matching -----------------------------------------
13
58
  # BS.1770 integrated-loudness measurement requires at least 400 ms of audio
14
59
  # (one gating block). Below this, fall back to peak match -- pyloudnorm
15
60
  # returns -inf or warns, neither of which gives a usable gain.
@@ -64,7 +109,7 @@ def loudness_match(target: Audio, reference: Audio) -> Audio:
64
109
 
65
110
  from videopython.ai._optional import require
66
111
 
67
- pyloudnorm = require("pyloudnorm", "ai", feature="loudness matching")
112
+ pyloudnorm = require("pyloudnorm", feature="loudness matching")
68
113
 
69
114
  target_lufs = pyloudnorm.Meter(target.metadata.sample_rate).integrated_loudness(target.data)
70
115
  reference_lufs = pyloudnorm.Meter(reference.metadata.sample_rate).integrated_loudness(reference.data)