videopython 0.39.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 (75) hide show
  1. {videopython-0.39.0 → videopython-0.41.0}/PKG-INFO +1 -1
  2. {videopython-0.39.0 → videopython-0.41.0}/pyproject.toml +1 -1
  3. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/__init__.py +0 -9
  4. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/exceptions.py +6 -13
  5. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/__init__.py +32 -1
  6. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/__init__.py +8 -0
  7. videopython-0.41.0/src/videopython/editing/_ass.py +254 -0
  8. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/operation.py +29 -2
  9. videopython-0.41.0/src/videopython/editing/streaming.py +547 -0
  10. videopython-0.41.0/src/videopython/editing/transcription_overlay.py +397 -0
  11. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/video_edit.py +247 -75
  12. videopython-0.39.0/src/videopython/base/image_text.py +0 -1217
  13. videopython-0.39.0/src/videopython/editing/streaming.py +0 -295
  14. videopython-0.39.0/src/videopython/editing/transcription_overlay.py +0 -591
  15. {videopython-0.39.0 → videopython-0.41.0}/.gitignore +0 -0
  16. {videopython-0.39.0 → videopython-0.41.0}/LICENSE +0 -0
  17. {videopython-0.39.0 → videopython-0.41.0}/README.md +0 -0
  18. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/__init__.py +0 -0
  19. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/__init__.py +0 -0
  20. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/_device.py +0 -0
  21. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  22. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/config.py +0 -0
  23. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  24. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  25. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  26. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/models.py +0 -0
  27. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  28. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/quality.py +0 -0
  29. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/remux.py +0 -0
  30. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/timing.py +0 -0
  31. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  32. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/effects.py +0 -0
  33. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/__init__.py +0 -0
  34. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/audio.py +0 -0
  35. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/image.py +0 -0
  36. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/qwen3.py +0 -0
  37. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/translation.py +0 -0
  38. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/video.py +0 -0
  39. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/transforms.py +0 -0
  40. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/__init__.py +0 -0
  41. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/audio.py +0 -0
  42. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/faces.py +0 -0
  43. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/image.py +0 -0
  44. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/objects.py +0 -0
  45. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/separation.py +0 -0
  46. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/temporal.py +0 -0
  47. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  48. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  49. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/models.py +0 -0
  50. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  51. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  52. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/audio/__init__.py +0 -0
  53. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/audio/analysis.py +0 -0
  54. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/audio/audio.py +0 -0
  55. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/_dimensions.py +0 -0
  56. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/_ffmpeg.py +0 -0
  57. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/_video_io.py +0 -0
  58. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/description.py +0 -0
  59. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/draw_detections.py +0 -0
  60. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  61. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  62. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  63. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  64. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  65. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  66. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  67. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  68. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  69. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  70. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/transcription.py +0 -0
  71. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/video.py +0 -0
  72. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/_easing.py +0 -0
  73. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/effects.py +0 -0
  74. {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/transforms.py +0 -0
  75. {videopython-0.39.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.39.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.39.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,8 +91,9 @@ 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"
95
+ # Streaming strictness (opt-in via strict_streaming).
96
+ STREAMING_FALLBACK = "streaming_fallback"
108
97
 
109
98
 
110
99
  @dataclass
@@ -113,6 +102,9 @@ class PlanError:
113
102
 
114
103
  ``location`` is a path into the plan (e.g. ``'segments[1].operations[0]'``);
115
104
  the remaining fields are populated when meaningful for the ``code``.
105
+ ``detail`` carries a short human-readable cause when the code alone is not
106
+ actionable (e.g. *why* an op forces the eager fallback) -- prose meant for
107
+ LLM refine-loop feedback, not for branching.
116
108
  """
117
109
 
118
110
  code: PlanErrorCode
@@ -122,6 +114,7 @@ class PlanError:
122
114
  value: float | None = None
123
115
  limit: float | None = None
124
116
  predicted_duration: float | None = None
117
+ detail: str | None = None
125
118
 
126
119
 
127
120
  @dataclass
@@ -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,
@@ -22,6 +23,7 @@ from .effects import (
22
23
  Zoom,
23
24
  )
24
25
  from .operation import FilterCtx, OpCategory, Operation, TimeRange
26
+ from .streaming import OpStreamability, StreamabilityReport, StreamingClass
25
27
  from .transcription_overlay import SubtitleRegion, SubtitleStyle, TranscriptionOverlay
26
28
  from .transforms import (
27
29
  Crop,
@@ -82,4 +84,10 @@ __all__ = [
82
84
  # Plan runner
83
85
  "VideoEdit",
84
86
  "SegmentConfig",
87
+ # Subtitle placement
88
+ "AnchorPoint",
89
+ # Streamability report
90
+ "StreamabilityReport",
91
+ "OpStreamability",
92
+ "StreamingClass",
85
93
  ]
@@ -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