videopython 0.40.0__tar.gz → 0.41.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 (74) hide show
  1. {videopython-0.40.0 → videopython-0.41.0}/PKG-INFO +1 -1
  2. {videopython-0.40.0 → videopython-0.41.0}/pyproject.toml +1 -1
  3. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/__init__.py +0 -9
  4. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/exceptions.py +0 -13
  5. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/__init__.py +32 -1
  6. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/__init__.py +3 -0
  7. videopython-0.41.0/src/videopython/editing/_ass.py +254 -0
  8. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/operation.py +29 -2
  9. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/streaming.py +62 -4
  10. videopython-0.41.0/src/videopython/editing/transcription_overlay.py +397 -0
  11. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/video_edit.py +161 -100
  12. videopython-0.40.0/src/videopython/base/image_text.py +0 -1217
  13. videopython-0.40.0/src/videopython/editing/transcription_overlay.py +0 -591
  14. {videopython-0.40.0 → videopython-0.41.0}/.gitignore +0 -0
  15. {videopython-0.40.0 → videopython-0.41.0}/LICENSE +0 -0
  16. {videopython-0.40.0 → videopython-0.41.0}/README.md +0 -0
  17. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/__init__.py +0 -0
  18. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/__init__.py +0 -0
  19. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/_device.py +0 -0
  20. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  21. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/config.py +0 -0
  22. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  23. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  24. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  25. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/models.py +0 -0
  26. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  27. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/quality.py +0 -0
  28. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/remux.py +0 -0
  29. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/timing.py +0 -0
  30. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  31. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/effects.py +0 -0
  32. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/__init__.py +0 -0
  33. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/audio.py +0 -0
  34. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/image.py +0 -0
  35. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/qwen3.py +0 -0
  36. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/translation.py +0 -0
  37. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/video.py +0 -0
  38. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/transforms.py +0 -0
  39. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/__init__.py +0 -0
  40. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/audio.py +0 -0
  41. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/faces.py +0 -0
  42. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/image.py +0 -0
  43. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/objects.py +0 -0
  44. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/separation.py +0 -0
  45. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/temporal.py +0 -0
  46. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  47. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  48. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/models.py +0 -0
  49. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  50. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  51. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/audio/__init__.py +0 -0
  52. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/audio/analysis.py +0 -0
  53. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/audio/audio.py +0 -0
  54. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/_dimensions.py +0 -0
  55. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/_ffmpeg.py +0 -0
  56. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/_video_io.py +0 -0
  57. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/description.py +0 -0
  58. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/draw_detections.py +0 -0
  59. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  60. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  61. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  62. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  63. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  64. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  65. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  66. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  67. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  68. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  69. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/transcription.py +0 -0
  70. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/video.py +0 -0
  71. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/_easing.py +0 -0
  72. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/effects.py +0 -0
  73. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/transforms.py +0 -0
  74. {videopython-0.40.0 → videopython-0.41.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.40.0
3
+ Version: 0.41.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.40.0"
3
+ version = "0.41.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -14,19 +14,16 @@ from .draw_detections import DetectionStyle, class_color, draw_detections
14
14
  from .exceptions import (
15
15
  AudioError,
16
16
  AudioLoadError,
17
- OutOfBoundsError,
18
17
  PlanError,
19
18
  PlanErrorCode,
20
19
  PlanRepair,
21
20
  PlanValidationError,
22
- TextRenderError,
23
21
  TransformError,
24
22
  VideoError,
25
23
  VideoLoadError,
26
24
  VideoMetadataError,
27
25
  VideoPythonError,
28
26
  )
29
- from .image_text import AnchorPoint, ImageText, TextAlign
30
27
  from .transcription import Transcription, TranscriptionSegment, TranscriptionWord
31
28
  from .video import FrameIterator, Video, VideoMetadata
32
29
 
@@ -43,17 +40,11 @@ __all__ = [
43
40
  "AudioError",
44
41
  "AudioLoadError",
45
42
  "TransformError",
46
- "TextRenderError",
47
- "OutOfBoundsError",
48
43
  # Structured plan validation / repair
49
44
  "PlanError",
50
45
  "PlanErrorCode",
51
46
  "PlanValidationError",
52
47
  "PlanRepair",
53
- # Text rendering primitives
54
- "ImageText",
55
- "TextAlign",
56
- "AnchorPoint",
57
48
  # Detection overlay renderer (AI-free)
58
49
  "draw_detections",
59
50
  "DetectionStyle",
@@ -66,18 +66,6 @@ class TransformError(VideoPythonError):
66
66
  pass
67
67
 
68
68
 
69
- class TextRenderError(VideoPythonError):
70
- """Base exception for text rendering errors."""
71
-
72
- pass
73
-
74
-
75
- class OutOfBoundsError(TextRenderError):
76
- """Raised when text would be rendered outside image bounds."""
77
-
78
- pass
79
-
80
-
81
69
  class PlanErrorCode(str, Enum):
82
70
  """Machine-readable failure classes raised while validating a ``VideoEdit``.
83
71
 
@@ -103,7 +91,6 @@ class PlanErrorCode(str, Enum):
103
91
  # Assembly / structural.
104
92
  UNKNOWN_OP = "unknown_op"
105
93
  CONCAT_MISMATCH = "concat_mismatch"
106
- SUBTITLE_UNFITTABLE = "subtitle_unfittable"
107
94
  POST_OP_REQUIRES_CONTEXT = "post_op_requires_context"
108
95
  # Streaming strictness (opt-in via strict_streaming).
109
96
  STREAMING_FALLBACK = "streaming_fallback"
@@ -12,10 +12,18 @@ unreadable font:
12
12
  from __future__ import annotations
13
13
 
14
14
  from importlib.resources import as_file, files
15
+ from pathlib import Path
15
16
 
16
17
  from PIL import ImageFont
17
18
 
18
- __all__ = ["BUNDLED_FONTS", "DEFAULT_FONT_FILENAME", "FONT_NAMES", "load_font"]
19
+ __all__ = [
20
+ "BUNDLED_FONTS",
21
+ "BUNDLED_FONT_FAMILIES",
22
+ "DEFAULT_FONT_FILENAME",
23
+ "FONT_NAMES",
24
+ "bundled_fonts_dir",
25
+ "load_font",
26
+ ]
19
27
 
20
28
  DEFAULT_FONT_FILENAME = "DejaVuSans.ttf"
21
29
 
@@ -31,6 +39,29 @@ BUNDLED_FONTS: dict[str, str] = {
31
39
 
32
40
  FONT_NAMES: list[str] = sorted(BUNDLED_FONTS)
33
41
 
42
+ # Registered NAME (or None for the default) -> (family name, is-bold-face),
43
+ # exactly as declared in each file's name table. Renderers that match fonts by
44
+ # family name -- libass via the ffmpeg ``subtitles=`` filter's ``fontsdir`` --
45
+ # need these, not the filenames. Verified against the bundled .ttf files
46
+ # (PIL ``ImageFont.getname()``); a test pins them.
47
+ BUNDLED_FONT_FAMILIES: dict[str | None, tuple[str, bool]] = {
48
+ None: ("DejaVu Sans", False),
49
+ "poppins-bold": ("Poppins", True),
50
+ "lato-bold": ("Lato", True),
51
+ "anton": ("Anton", False),
52
+ "bebas-neue": ("Bebas Neue", False),
53
+ }
54
+
55
+
56
+ def bundled_fonts_dir() -> Path:
57
+ """Filesystem directory holding the bundled .ttf files.
58
+
59
+ For ffmpeg/libass ``fontsdir`` wiring, which needs a real directory path.
60
+ Standard (non-zip) installs ship the package unpacked, so the resources
61
+ root is a plain directory.
62
+ """
63
+ return Path(str(files(__package__)))
64
+
34
65
 
35
66
  def _try_truetype(path: str, font_size: int) -> ImageFont.FreeTypeFont | None:
36
67
  try:
@@ -1,3 +1,4 @@
1
+ from ._ass import AnchorPoint
1
2
  from .effects import (
2
3
  Blur,
3
4
  ChromaticAberration,
@@ -83,6 +84,8 @@ __all__ = [
83
84
  # Plan runner
84
85
  "VideoEdit",
85
86
  "SegmentConfig",
87
+ # Subtitle placement
88
+ "AnchorPoint",
86
89
  # Streamability report
87
90
  "StreamabilityReport",
88
91
  "OpStreamability",
@@ -0,0 +1,254 @@
1
+ """ASS (Advanced SubStation Alpha) generation for libass subtitle burn-in.
2
+
3
+ Compiles a segment-local, word-level :class:`Transcription` plus the resolved
4
+ ``add_subtitles`` look into an ``.ass`` document that ffmpeg's ``subtitles=``
5
+ filter (libass) renders natively at decode time -- zero per-frame Python.
6
+
7
+ The word-level highlight (active word in the highlight color, enlarged by the
8
+ size multiplier) cannot be expressed with ASS karaoke ``\\k`` tags -- those are
9
+ two-state (upcoming vs spoken-and-active) while this look is three-state
10
+ (active word only). Instead each cue is emitted as a sequence of word-state
11
+ ``Dialogue`` events tiling the cue's time range: one event per active-word
12
+ interval with inline ``\\1c``/``\\fscx``/``\\fscy`` overrides on that word, and
13
+ plain events for the gaps between words. Event boundaries are rounded to ASS's
14
+ centisecond precision *before* deriving the intervals, so adjacent events tile
15
+ exactly (no double-draw overlap, no blank gaps).
16
+
17
+ Placement uses ``{\\an..\\pos(x,y)}`` on every event -- the exact box-anchor
18
+ semantics of the Python renderer (a box anchored at a normalized position) --
19
+ rather than margin arithmetic; ``MarginL/R`` are still set so libass wraps at
20
+ the same ``box_width`` the Python renderer uses.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from enum import Enum
27
+
28
+ from videopython.base.transcription import Transcription
29
+
30
+ __all__ = ["AnchorPoint", "AssLook", "build_ass", "escape_filter_value"]
31
+
32
+ RGBColor = tuple[int, int, int]
33
+ RGBAColor = tuple[int, int, int, int]
34
+
35
+
36
+ class AnchorPoint(str, Enum):
37
+ """Which point of the subtitle box sits at the configured position."""
38
+
39
+ TOP_LEFT = "top-left"
40
+ TOP_CENTER = "top-center"
41
+ TOP_RIGHT = "top-right"
42
+ CENTER_LEFT = "center-left"
43
+ CENTER = "center"
44
+ CENTER_RIGHT = "center-right"
45
+ BOTTOM_LEFT = "bottom-left"
46
+ BOTTOM_CENTER = "bottom-center"
47
+ BOTTOM_RIGHT = "bottom-right"
48
+
49
+
50
+ # AnchorPoint -> ASS \an numpad code (7-8-9 top, 4-5-6 middle, 1-2-3 bottom).
51
+ _ANCHOR_TO_AN: dict[AnchorPoint, int] = {
52
+ AnchorPoint.TOP_LEFT: 7,
53
+ AnchorPoint.TOP_CENTER: 8,
54
+ AnchorPoint.TOP_RIGHT: 9,
55
+ AnchorPoint.CENTER_LEFT: 4,
56
+ AnchorPoint.CENTER: 5,
57
+ AnchorPoint.CENTER_RIGHT: 6,
58
+ AnchorPoint.BOTTOM_LEFT: 1,
59
+ AnchorPoint.BOTTOM_CENTER: 2,
60
+ AnchorPoint.BOTTOM_RIGHT: 3,
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AssLook:
66
+ """The resolved ``add_subtitles`` look, expressed in ASS terms.
67
+
68
+ ``font_family`` is the family name inside the font file (libass matches by
69
+ name table, not filename); ``bold`` selects the bold face. ``background``
70
+ of ``None`` means no box (BorderStyle 1); otherwise the RGBA box uses
71
+ libass's BorderStyle 4 (whole-event background, burn-in only) with
72
+ ``background_padding`` as the box padding.
73
+ """
74
+
75
+ font_family: str
76
+ bold: bool
77
+ font_px: int
78
+ text_color: RGBColor
79
+ highlight_color: RGBColor
80
+ outline_px: int
81
+ background: RGBAColor | None
82
+ background_padding: int
83
+ highlight_size_multiplier: float
84
+ position: tuple[float, float]
85
+ anchor: AnchorPoint
86
+ box_width: float
87
+
88
+
89
+ def _ass_time(seconds: float) -> str:
90
+ """``H:MM:SS.cc`` -- ASS timestamps are centisecond-precision only."""
91
+ cs = max(0, round(seconds * 100))
92
+ hours, rem = divmod(cs, 360_000)
93
+ minutes, rem = divmod(rem, 6_000)
94
+ secs, centis = divmod(rem, 100)
95
+ return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
96
+
97
+
98
+ def _ass_color(rgb: RGBColor, opacity: int = 255) -> str:
99
+ """RGB + opacity (0-255, 255=opaque) -> ``&HAABBGGRR`` (ASS alpha: 00=opaque)."""
100
+ r, g, b = rgb
101
+ alpha = 255 - max(0, min(255, opacity))
102
+ return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
103
+
104
+
105
+ def _ass_color_tag(rgb: RGBColor) -> str:
106
+ """RGB -> the inline override form ``&HBBGGRR&`` (no alpha, trailing ``&``)."""
107
+ r, g, b = rgb
108
+ return f"&H{b:02X}{g:02X}{r:02X}&"
109
+
110
+
111
+ def _escape_text(text: str) -> str:
112
+ """Neutralize ASS markup in transcribed text.
113
+
114
+ ``{``/``}`` delimit override blocks and ``\\`` introduces escapes/breaks;
115
+ ASS has no escape sequence for them, so they are substituted.
116
+ """
117
+ return text.replace("\\", "/").replace("{", "(").replace("}", ")").replace("\n", " ")
118
+
119
+
120
+ def escape_filter_value(value: str) -> str:
121
+ """Quote a string for use as an ffmpeg filter option value.
122
+
123
+ Escapes the option-value level metacharacters (``\\``, ``'``, ``:``) and
124
+ wraps the result in single quotes so the filtergraph-level separators
125
+ (``,``, ``;``, ``[``, ``]``) pass through untouched.
126
+ """
127
+ escaped = value.replace("\\", "\\\\").replace("'", "\\'").replace(":", "\\:")
128
+ return f"'{escaped}'"
129
+
130
+
131
+ def _word_state_intervals(
132
+ start: float, end: float, word_spans: list[tuple[float, float]]
133
+ ) -> list[tuple[int, int, int | None]]:
134
+ """Tile ``[start, end)`` into centisecond intervals keyed by active word.
135
+
136
+ Returns ``(start_cs, end_cs, highlight_index)`` tuples; ``None`` means no
137
+ word is active (the gap before/between/after words). Boundaries are
138
+ rounded to centiseconds first and durations derived from the rounded
139
+ boundaries, so rounding error cannot accumulate into overlaps or gaps.
140
+ Zero-length intervals are dropped.
141
+ """
142
+ cue_start = round(start * 100)
143
+ cue_end = round(end * 100)
144
+ intervals: list[tuple[int, int, int | None]] = []
145
+ cursor = cue_start
146
+ for i, (w_start, w_end) in enumerate(word_spans):
147
+ if cursor >= cue_end:
148
+ break
149
+ ws = min(cue_end, max(cursor, round(w_start * 100)))
150
+ we = min(cue_end, round(w_end * 100))
151
+ if ws > cursor:
152
+ intervals.append((cursor, ws, None))
153
+ cursor = ws
154
+ if we > cursor:
155
+ intervals.append((cursor, we, i))
156
+ cursor = we
157
+ if cue_end > cursor:
158
+ intervals.append((cursor, cue_end, None))
159
+ return intervals
160
+
161
+
162
+ def build_ass(
163
+ transcription: Transcription,
164
+ *,
165
+ width: int,
166
+ height: int,
167
+ look: AssLook,
168
+ window: tuple[float | None, float | None] | None = None,
169
+ ) -> str:
170
+ """Compile a segment-local transcription into a complete ASS document.
171
+
172
+ ``transcription`` must already be on the timeline of the frames the
173
+ ``subtitles=`` filter will see (the plan builder re-bases context onto the
174
+ cut segment, and the ``-ss``-before-``-i`` decode resets PTS to zero, so
175
+ segment-local is exactly right). ``window`` clips events to the effect's
176
+ active range -- the ``subtitles`` filter has no timeline (``enable=``)
177
+ support, so windowing happens here, at compile time.
178
+
179
+ ``PlayResX/Y`` are set to the frame size entering the filter, so one
180
+ script pixel is one video pixel and ``font_px`` carries over 1:1.
181
+ """
182
+ win_start = window[0] if window is not None else None
183
+ win_stop = window[1] if window is not None else None
184
+
185
+ primary = _ass_color(look.text_color)
186
+ outline_colour = _ass_color((0, 0, 0))
187
+ if look.background is not None:
188
+ border_style = 4 # libass extension: whole-event background box (burn-in only)
189
+ back_colour = _ass_color(look.background[:3], look.background[3])
190
+ shadow = look.background_padding # BorderStyle 4 reads box padding from Shadow
191
+ else:
192
+ border_style = 1
193
+ back_colour = _ass_color((0, 0, 0))
194
+ shadow = 0
195
+
196
+ an = _ANCHOR_TO_AN[look.anchor]
197
+ pos_x = look.position[0] * width
198
+ pos_y = look.position[1] * height
199
+ # Margins do not place positioned events, but they still define the wrap
200
+ # width (PlayResX - MarginL - MarginR), which is how box_width carries over.
201
+ margin_h = max(0, round((1.0 - look.box_width) / 2.0 * width))
202
+
203
+ lines = [
204
+ "[Script Info]",
205
+ "ScriptType: v4.00+",
206
+ f"PlayResX: {width}",
207
+ f"PlayResY: {height}",
208
+ "ScaledBorderAndShadow: yes",
209
+ "WrapStyle: 0",
210
+ "",
211
+ "[V4+ Styles]",
212
+ (
213
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, "
214
+ "Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, "
215
+ "Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
216
+ ),
217
+ (
218
+ f"Style: Default,{look.font_family},{look.font_px},{primary},{primary},{outline_colour},"
219
+ f"{back_colour},{-1 if look.bold else 0},0,0,0,100,100,0,0,{border_style},{look.outline_px},"
220
+ f"{shadow},{an},{margin_h},{margin_h},0,1"
221
+ ),
222
+ "",
223
+ "[Events]",
224
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
225
+ ]
226
+
227
+ scale = round(look.highlight_size_multiplier * 100)
228
+ highlight = _ass_color_tag(look.highlight_color)
229
+ placement = f"\\an{an}\\pos({pos_x:.1f},{pos_y:.1f})"
230
+
231
+ for segment in transcription.segments:
232
+ words = [_escape_text(w.word) for w in segment.words]
233
+ if not any(w.strip() for w in words):
234
+ continue
235
+ start, end = segment.start, segment.end
236
+ if win_start is not None:
237
+ start = max(start, win_start)
238
+ if win_stop is not None:
239
+ end = min(end, win_stop)
240
+ if end <= start:
241
+ continue
242
+ spans = [(w.start, w.end) for w in segment.words]
243
+ for start_cs, end_cs, hl in _word_state_intervals(start, end, spans):
244
+ if hl is None:
245
+ text = " ".join(words)
246
+ else:
247
+ marked = f"{{\\1c{highlight}\\fscx{scale}\\fscy{scale}}}{words[hl]}{{\\r}}"
248
+ text = " ".join([*words[:hl], marked, *words[hl + 1 :]])
249
+ lines.append(
250
+ f"Dialogue: 0,{_ass_time(start_cs / 100)},{_ass_time(end_cs / 100)},Default,,0,0,0,,"
251
+ f"{{{placement}}}{text}"
252
+ )
253
+
254
+ return "\n".join(lines) + "\n"
@@ -31,8 +31,9 @@ Subclass contract::
31
31
  from __future__ import annotations
32
32
 
33
33
  import copy
34
- from dataclasses import dataclass
34
+ from dataclasses import dataclass, field
35
35
  from enum import Enum
36
+ from pathlib import Path
36
37
  from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, NamedTuple, Union, get_args, get_origin
37
38
 
38
39
  import numpy as np
@@ -99,11 +100,23 @@ class BoundedTimeField(NamedTuple):
99
100
 
100
101
  @dataclass(frozen=True)
101
102
  class FilterCtx:
102
- """Current pipeline state (post-prior-ops) when compiling to ffmpeg."""
103
+ """Current pipeline state (post-prior-ops) when compiling to ffmpeg.
104
+
105
+ ``context`` carries the resolved, segment-local runtime context (the same
106
+ re-based values ``streaming_init`` receives) so a context-consuming op can
107
+ compile itself into the filter chain (e.g. ``add_subtitles`` consuming the
108
+ transcription to write an ``.ass`` file). Empty when no context applies.
109
+
110
+ ``owned_files`` collects temp files a compilation creates (the ``.ass``
111
+ file a ``subtitles=`` entry references); the plan runner deletes them once
112
+ streaming finishes or the plan is abandoned.
113
+ """
103
114
 
104
115
  width: int
105
116
  height: int
106
117
  fps: float
118
+ context: dict[str, Any] = field(default_factory=dict)
119
+ owned_files: list[Path] = field(default_factory=list)
107
120
 
108
121
 
109
122
  LLM_HIDDEN_KEY = "llm_hidden"
@@ -391,6 +404,20 @@ class Effect(Operation):
391
404
  description="Time window for the effect in seconds. Omit to apply across the full duration.",
392
405
  )
393
406
 
407
+ @property
408
+ def compiles_to_filter(self) -> bool:
409
+ """Whether this effect joins the decode filter chain instead of scheduling per-frame Python.
410
+
411
+ When True, the streaming plan builder calls :meth:`to_ffmpeg_filter`
412
+ (with the segment's resolved context on the :class:`FilterCtx`) and, if
413
+ it compiles, appends the result to the vf chain at this op's plan
414
+ position -- the Filter class of the streaming contract. Instance-level
415
+ rather than a ClassVar because it may depend on field values (e.g.
416
+ ``add_subtitles``'s ``renderer``). False by default: effects normally
417
+ stream via ``streaming_init``/``process_frame``.
418
+ """
419
+ return False
420
+
394
421
  def apply(self, video: Video, **context: Any) -> Video:
395
422
  from videopython.base.video import Video as _Video
396
423
 
@@ -122,12 +122,22 @@ def analyze_streamability(
122
122
  entries: list[OpStreamability] = []
123
123
  for i, ops in enumerate(segment_operations):
124
124
  seen_effect = False
125
+ seen_encode_filter = False
125
126
  for j, op in enumerate(ops):
126
127
  location = f"segments[{i}].operations[{j}]"
127
128
  if isinstance(op, Effect):
128
- if op.streamable:
129
- entries.append(OpStreamability(location, op.op, StreamingClass.FRAME_EFFECT))
130
- else:
129
+ if op.streamable and op.compiles_to_filter:
130
+ # Filter-class effect (add_subtitles): joins the decode
131
+ # filter chain at this plan position -- or the encode
132
+ # chain when frame effects precede it -- so it streams in
133
+ # plan order either way and does not block later
134
+ # transforms the way a scheduled frame effect does. The
135
+ # builder mirrors this exactly.
136
+ entries.append(OpStreamability(location, op.op, StreamingClass.FILTER))
137
+ if seen_effect:
138
+ seen_encode_filter = True
139
+ continue
140
+ if not op.streamable:
131
141
  entries.append(
132
142
  OpStreamability(
133
143
  location,
@@ -136,6 +146,21 @@ def analyze_streamability(
136
146
  reason="effect has no streaming implementation (streamable=False)",
137
147
  )
138
148
  )
149
+ elif seen_encode_filter:
150
+ entries.append(
151
+ OpStreamability(
152
+ location,
153
+ op.op,
154
+ StreamingClass.EAGER,
155
+ reason=(
156
+ "frame effect follows burned-in subtitles (an encode-stage filter) in "
157
+ "plan order; the filter runs after every frame effect, so plan order "
158
+ "cannot be preserved -- move the effect before the subtitles to stream"
159
+ ),
160
+ )
161
+ )
162
+ else:
163
+ entries.append(OpStreamability(location, op.op, StreamingClass.FRAME_EFFECT))
139
164
  seen_effect = True
140
165
  elif op.requires:
141
166
  entries.append(
@@ -198,6 +223,18 @@ def analyze_streamability(
198
223
  ),
199
224
  )
200
225
  )
226
+ elif isinstance(op, Effect) and op.streamable and op.compiles_to_filter:
227
+ entries.append(
228
+ OpStreamability(
229
+ location,
230
+ op.op,
231
+ StreamingClass.EAGER,
232
+ reason=(
233
+ "filter-class post-operations cannot fold into the segment schedule -- "
234
+ "move the op into the segment to stream"
235
+ ),
236
+ )
237
+ )
201
238
  elif isinstance(op, Effect) and op.streamable:
202
239
  entries.append(OpStreamability(location, op.op, StreamingClass.FRAME_EFFECT))
203
240
  elif isinstance(op, Effect):
@@ -240,7 +277,17 @@ class EffectScheduleEntry:
240
277
 
241
278
  @dataclass
242
279
  class StreamingSegmentPlan:
243
- """Describes how to stream-process one video segment."""
280
+ """Describes how to stream-process one video segment.
281
+
282
+ ``vf_filters`` run at decode time, before every scheduled effect;
283
+ ``post_vf_filters`` run at encode time (``FrameEncoder``'s ``-vf``),
284
+ after every scheduled effect -- where a filter-class op that follows
285
+ frame effects in plan order lands (e.g. ``[fade, add_subtitles]``).
286
+
287
+ ``owned_temp_files`` are compile-time artifacts referenced by the filter
288
+ chains (e.g. the ``.ass`` file behind a ``subtitles=`` entry); the runner
289
+ deletes them once streaming finishes or the plan is abandoned.
290
+ """
244
291
 
245
292
  source_path: Path
246
293
  start_second: float
@@ -250,6 +297,8 @@ class StreamingSegmentPlan:
250
297
  output_height: int
251
298
  vf_filters: list[str] = field(default_factory=list)
252
299
  effect_schedule: list[EffectScheduleEntry] = field(default_factory=list)
300
+ post_vf_filters: list[str] = field(default_factory=list)
301
+ owned_temp_files: list[Path] = field(default_factory=list)
253
302
 
254
303
 
255
304
  class FrameEncoder:
@@ -268,6 +317,7 @@ class FrameEncoder:
268
317
  format: str = "mp4",
269
318
  preset: str = "medium",
270
319
  crf: int = 23,
320
+ vf_filters: list[str] | None = None,
271
321
  ):
272
322
  self._output_path = output_path
273
323
  self._width = width
@@ -277,6 +327,7 @@ class FrameEncoder:
277
327
  self._format = format
278
328
  self._preset = preset
279
329
  self._crf = crf
330
+ self._vf_filters = vf_filters or []
280
331
  self._stack: ExitStack | None = None
281
332
  self._process: subprocess.Popen[bytes] | None = None
282
333
 
@@ -302,6 +353,12 @@ class FrameEncoder:
302
353
  if self._audio_path is not None:
303
354
  cmd.extend(["-i", str(self._audio_path)])
304
355
 
356
+ if self._vf_filters:
357
+ # Encode-stage filters: run after every per-frame effect (frames
358
+ # arrive via stdin already processed). The rawvideo pipe's PTS
359
+ # start at zero, so time-based filters (subtitles=) see the same
360
+ # segment-local timeline the decode side uses.
361
+ cmd.extend(["-vf", ",".join(self._vf_filters)])
305
362
  cmd.extend(
306
363
  [
307
364
  "-c:v",
@@ -424,6 +481,7 @@ def stream_segment(
424
481
  format=format,
425
482
  preset=preset,
426
483
  crf=crf,
484
+ vf_filters=plan.post_vf_filters,
427
485
  ) as encoder:
428
486
  logger.info("Streaming frames...")
429
487
  frame_count = 0