bladeworks 0.1.0__py3-none-any.whl

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 (178) hide show
  1. bladeworks/__init__.py +56 -0
  2. bladeworks/__main__.py +4 -0
  3. bladeworks/cli.py +728 -0
  4. bladeworks/core/__init__.py +34 -0
  5. bladeworks/core/animation.py +893 -0
  6. bladeworks/core/animation_execution.py +817 -0
  7. bladeworks/core/audio_enhancements.py +673 -0
  8. bladeworks/core/audio_execution.py +1848 -0
  9. bladeworks/core/audio_ir.py +1971 -0
  10. bladeworks/core/basic_effects.py +168 -0
  11. bladeworks/core/bindings.py +196 -0
  12. bladeworks/core/capabilities.py +257 -0
  13. bladeworks/core/cohort_effects.py +702 -0
  14. bladeworks/core/color.py +324 -0
  15. bladeworks/core/compiler.py +3142 -0
  16. bladeworks/core/composition_diagnostics.py +629 -0
  17. bladeworks/core/composition_ir.py +3154 -0
  18. bladeworks/core/composition_planning.py +708 -0
  19. bladeworks/core/composition_shadow.py +687 -0
  20. bladeworks/core/compositor.py +1186 -0
  21. bladeworks/core/effect_parameter_semantics.py +165 -0
  22. bladeworks/core/effect_parameters.py +193 -0
  23. bladeworks/core/errors.py +21 -0
  24. bladeworks/core/filter_text.py +48 -0
  25. bladeworks/core/geometry.py +1711 -0
  26. bladeworks/core/group_surface.py +664 -0
  27. bladeworks/core/keyer.py +280 -0
  28. bladeworks/core/masks.py +453 -0
  29. bladeworks/core/missing_media.py +130 -0
  30. bladeworks/core/model.py +985 -0
  31. bladeworks/core/multicam_execution.py +496 -0
  32. bladeworks/core/output_color.py +52 -0
  33. bladeworks/core/parser.py +1257 -0
  34. bladeworks/core/pixel_domains.py +433 -0
  35. bladeworks/core/render_profile.py +311 -0
  36. bladeworks/core/render_sources.py +475 -0
  37. bladeworks/core/report.py +118 -0
  38. bladeworks/core/retime.py +967 -0
  39. bladeworks/core/retime_execution.py +1515 -0
  40. bladeworks/core/spatial_intrinsics.py +1749 -0
  41. bladeworks/core/story_containers.py +524 -0
  42. bladeworks/core/story_ir.py +1042 -0
  43. bladeworks/core/text.py +1032 -0
  44. bladeworks/core/text_templates.py +934 -0
  45. bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
  46. bladeworks/effects/__init__.py +1 -0
  47. bladeworks/effects/contract.py +162 -0
  48. bladeworks/examples/__init__.py +80 -0
  49. bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
  50. bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
  51. bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
  52. bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
  53. bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
  54. bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
  55. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
  56. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
  57. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
  58. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
  59. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
  60. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
  61. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
  62. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
  63. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
  64. bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
  65. bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
  66. bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
  67. bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
  68. bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
  69. bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
  70. bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
  71. bladeworks/executor.py +293 -0
  72. bladeworks/preview/__init__.py +11 -0
  73. bladeworks/preview/application.py +84 -0
  74. bladeworks/preview/audio.py +104 -0
  75. bladeworks/preview/capabilities.py +457 -0
  76. bladeworks/preview/contracts.py +157 -0
  77. bladeworks/preview/events.py +55 -0
  78. bladeworks/preview/export.py +57 -0
  79. bladeworks/preview/history.py +197 -0
  80. bladeworks/preview/main.py +19 -0
  81. bladeworks/preview/media.py +23 -0
  82. bladeworks/preview/media_library.py +381 -0
  83. bladeworks/preview/media_reporting.py +55 -0
  84. bladeworks/preview/media_routes.py +155 -0
  85. bladeworks/preview/media_visuals.py +225 -0
  86. bladeworks/preview/producer.py +211 -0
  87. bladeworks/preview/provider.py +74 -0
  88. bladeworks/preview/rawframe.py +218 -0
  89. bladeworks/preview/render_jobs.py +311 -0
  90. bladeworks/preview/routes.py +375 -0
  91. bladeworks/preview/runner.py +349 -0
  92. bladeworks/preview/security.py +61 -0
  93. bladeworks/preview/service.py +234 -0
  94. bladeworks/preview/session.py +457 -0
  95. bladeworks/preview/source.py +603 -0
  96. bladeworks/preview/source_routes.py +151 -0
  97. bladeworks/preview/studio.py +77 -0
  98. bladeworks/preview/webrtc.py +295 -0
  99. bladeworks/spatial_luts/manifest.json +17 -0
  100. bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
  101. bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
  102. bladeworks/studio_static/__init__.py +1 -0
  103. bladeworks/studio_static/app.js +6519 -0
  104. bladeworks/studio_static/build-id.js +1 -0
  105. bladeworks/studio_static/capability-ui.js +68 -0
  106. bladeworks/studio_static/clip-state.js +335 -0
  107. bladeworks/studio_static/fcpxml.js +3313 -0
  108. bladeworks/studio_static/fixtures.js +393 -0
  109. bladeworks/studio_static/history.js +50 -0
  110. bladeworks/studio_static/index.html +24 -0
  111. bladeworks/studio_static/magnetic-timeline.js +1194 -0
  112. bladeworks/studio_static/mask-ui.js +189 -0
  113. bladeworks/studio_static/media-visuals.js +224 -0
  114. bladeworks/studio_static/mock-capabilities.js +409 -0
  115. bladeworks/studio_static/retime-ui.js +151 -0
  116. bladeworks/studio_static/runtime.js +1652 -0
  117. bladeworks/studio_static/scope-ui.js +150 -0
  118. bladeworks/studio_static/styles.css +1558 -0
  119. bladeworks/studio_static/templates/catalog.js +77 -0
  120. bladeworks/studio_static/templates/icons.js +84 -0
  121. bladeworks/studio_static/templates/inspector.js +553 -0
  122. bladeworks/studio_static/templates/library.js +61 -0
  123. bladeworks/studio_static/templates/media.js +185 -0
  124. bladeworks/studio_static/templates/shared.js +43 -0
  125. bladeworks/studio_static/templates/shell.js +94 -0
  126. bladeworks/studio_static/templates/timeline.js +377 -0
  127. bladeworks/studio_static/templates/topbar.js +83 -0
  128. bladeworks/studio_static/templates/types.js +7 -0
  129. bladeworks/studio_static/templates/viewer.js +174 -0
  130. bladeworks/studio_static/templates.js +31 -0
  131. bladeworks/studio_static/types.js +18 -0
  132. bladeworks/studio_static/ui.js +97 -0
  133. bladeworks/studio_static/viewer-geometry.js +55 -0
  134. bladeworks/tensor/__init__.py +131 -0
  135. bladeworks/tensor/audio_delivery.py +280 -0
  136. bladeworks/tensor/audio_pyav.py +360 -0
  137. bladeworks/tensor/blend.py +217 -0
  138. bladeworks/tensor/color.py +53 -0
  139. bladeworks/tensor/composite.py +33 -0
  140. bladeworks/tensor/decode.py +1309 -0
  141. bladeworks/tensor/decode_policy.py +363 -0
  142. bladeworks/tensor/effects.py +322 -0
  143. bladeworks/tensor/encode.py +614 -0
  144. bladeworks/tensor/errors.py +32 -0
  145. bladeworks/tensor/expr.py +775 -0
  146. bladeworks/tensor/fx_basic.py +946 -0
  147. bladeworks/tensor/fx_branched.py +640 -0
  148. bladeworks/tensor/fx_cohort.py +404 -0
  149. bladeworks/tensor/fx_color.py +851 -0
  150. bladeworks/tensor/fx_keyer.py +140 -0
  151. bladeworks/tensor/fx_mask.py +352 -0
  152. bladeworks/tensor/fx_warp.py +337 -0
  153. bladeworks/tensor/hdr.py +122 -0
  154. bladeworks/tensor/pipeline.py +401 -0
  155. bladeworks/tensor/plan.py +2231 -0
  156. bladeworks/tensor/renderer.py +1199 -0
  157. bladeworks/tensor/requirements.txt +28 -0
  158. bladeworks/tensor/resolution.py +246 -0
  159. bladeworks/tensor/sampler.py +598 -0
  160. bladeworks/tensor/support.py +169 -0
  161. bladeworks/tensor/swscale_fixedpoint.py +181 -0
  162. bladeworks/tensor/tr_equirect.py +420 -0
  163. bladeworks/tensor/tr_handlers.py +443 -0
  164. bladeworks/tensor/tr_phase5.py +697 -0
  165. bladeworks/tensor/transitions.py +424 -0
  166. bladeworks/transitions/__init__.py +10 -0
  167. bladeworks/transitions/carryovers.py +299 -0
  168. bladeworks/transitions/cohort.py +981 -0
  169. bladeworks/transitions/contract.py +490 -0
  170. bladeworks/transitions/equirectangular.py +915 -0
  171. bladeworks/transitions/light_deco.py +141 -0
  172. bladeworks/transitions/panel_motion.py +495 -0
  173. bladeworks/transitions/stock.py +270 -0
  174. bladeworks-0.1.0.dist-info/METADATA +66 -0
  175. bladeworks-0.1.0.dist-info/RECORD +178 -0
  176. bladeworks-0.1.0.dist-info/WHEEL +5 -0
  177. bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
  178. bladeworks-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,817 @@
1
+ """Compile typed FCPXML animation into bounded FFmpeg execution plans.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ ``TimelineAnimatedScalar`` / ``TimelineAnimatedVec2``
7
+ Exact source control points plus an exact piecewise ``RetimeMap``.
8
+
9
+ ``AnimationClock``
10
+ Defines how an FFmpeg filter's time variable relates to the clip. A
11
+ clip-local clock starts at the beginning of a rendered pre-roll window;
12
+ an absolute clock uses sequence time directly.
13
+
14
+ ``ClipTimeState``
15
+ One shared temporal snapshot for every geometry component. It records
16
+ clip-local time, retimed source time, segment ownership, and whether a
17
+ transition handle is holding an endpoint.
18
+
19
+ ``compile_scalar_expression`` / ``compile_vec2_expressions``
20
+ Clamp pre/post-roll to endpoint holds, map every retime segment without
21
+ averaging, then reproduce the animation kernel as deterministic FFmpeg
22
+ expressions.
23
+
24
+ ``sample_scalar_frames`` / ``sample_vec2_frames``
25
+ Produce an exact-time, per-frame command plan for filters such as
26
+ ``perspective`` that cannot evaluate an expression for every option.
27
+
28
+ Important invariants
29
+ --------------------
30
+
31
+ * Every time entering this boundary is an exact ``Fraction``. Generated
32
+ expressions retain rational times as numerator/denominator operations.
33
+ * Reverse, freeze, variable-rate, discontinuous, and repeated source ranges
34
+ are represented segment by segment. There is no average-speed fallback.
35
+ * Times before or after the clip hold the first or last rendered value. This
36
+ makes transition pre-roll and post-roll deterministic.
37
+ * Expression size, control-point count, retime-segment count, and their
38
+ product are bounded. Oversized plans fail explicitly instead of handing an
39
+ unbounded expression to FFmpeg.
40
+
41
+ Central integration seam
42
+ ------------------------
43
+
44
+ The central FFmpeg builder should consume the typed tracks already attached
45
+ to ``model.RenderTransformAnimation`` (and future opacity/audio records):
46
+
47
+ 1. Use ``compile_*_expression`` when a filter option supports per-frame
48
+ FFmpeg expressions.
49
+ 2. Use ``sample_*_frames`` plus a renderer-owned ``sendcmd`` file when a
50
+ filter option is command-only.
51
+ 3. Surface ``AnimationExecutionError`` as a compatibility finding or compile
52
+ error. Never fall back to a static or endpoint-average value.
53
+
54
+ This module deliberately does not import or mutate the shared compiler or
55
+ FFmpeg builder. It is the isolated Wave 2 contract for that later integration.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ from dataclasses import dataclass
61
+ from fractions import Fraction
62
+ import math
63
+ import re
64
+ from typing import Literal, Sequence, TypeAlias
65
+
66
+ from .animation import (
67
+ ScalarControlPoint,
68
+ TimelineAnimatedScalar,
69
+ TimelineAnimatedVec2,
70
+ Vec2ControlPoint,
71
+ )
72
+ from .retime import RetimeMap, SegmentKind
73
+
74
+
75
+ TimeOrigin: TypeAlias = Literal["clip_local", "absolute"]
76
+ ClockPhase: TypeAlias = Literal[
77
+ "transition_pre_roll",
78
+ "clip",
79
+ "transition_post_roll",
80
+ ]
81
+
82
+
83
+ class AnimationExecutionError(ValueError):
84
+ """Base error for an animation that cannot be executed faithfully."""
85
+
86
+
87
+ class AnimationExpressionLimitError(AnimationExecutionError):
88
+ """A valid animation exceeds the explicitly configured FFmpeg bounds."""
89
+
90
+
91
+ class AnimationClockError(AnimationExecutionError):
92
+ """The FFmpeg time origin or render window is ambiguous or invalid."""
93
+
94
+
95
+ def _exact_time(value: object, *, name: str) -> Fraction:
96
+ """Require an exact rational time at the FFmpeg execution boundary."""
97
+
98
+ if isinstance(value, bool):
99
+ raise AnimationClockError(f"{name} must be an exact Fraction, not bool")
100
+ if isinstance(value, Fraction):
101
+ return value
102
+ if isinstance(value, int):
103
+ return Fraction(value)
104
+ if isinstance(value, float):
105
+ raise AnimationClockError(f"{name} must be an exact Fraction, not float")
106
+ raise AnimationClockError(
107
+ f"{name} must be an exact Fraction, got {type(value).__name__}"
108
+ )
109
+
110
+
111
+ def _positive_int(value: object, *, name: str) -> int:
112
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
113
+ raise AnimationExpressionLimitError(f"{name} must be a positive integer")
114
+ return value
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class AnimationExpressionLimits:
119
+ """Hard resource limits for generated FFmpeg expressions.
120
+
121
+ Main callers:
122
+ - Registry-owned renderer configuration.
123
+
124
+ Why this exists:
125
+ - FFmpeg's expression parser is recursive. Explicit limits prevent a
126
+ large untrusted project from creating excessive parser work or memory.
127
+ """
128
+
129
+ max_control_points: int = 64
130
+ max_retime_segments: int = 64
131
+ max_segment_point_product: int = 1024
132
+ max_expression_chars: int = 65_536
133
+
134
+ def __post_init__(self) -> None:
135
+ for name in (
136
+ "max_control_points",
137
+ "max_retime_segments",
138
+ "max_segment_point_product",
139
+ "max_expression_chars",
140
+ ):
141
+ object.__setattr__(
142
+ self,
143
+ name,
144
+ _positive_int(getattr(self, name), name=name),
145
+ )
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class ClipTimeState:
150
+ """One exact clock/retime state shared by a clip's geometry tracks.
151
+
152
+ ``clip_time`` and ``track_time`` are endpoint-held during transition-only
153
+ pre/post-roll. ``source_time`` is evaluated through the same exact map as
154
+ video playback, including reverse and freeze segments.
155
+ """
156
+
157
+ input_time: Fraction
158
+ unclamped_clip_time: Fraction
159
+ clip_time: Fraction
160
+ track_time: Fraction
161
+ source_time: Fraction
162
+ phase: ClockPhase
163
+ retime_segment_index: int
164
+ retime_segment_kind: SegmentKind
165
+
166
+ @property
167
+ def is_endpoint_hold(self) -> bool:
168
+ return self.phase != "clip"
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class AnimationClock:
173
+ """Map an FFmpeg filter time variable onto one clip's retime domain.
174
+
175
+ ``clip_local`` means FFmpeg time zero is the start of the rendered window.
176
+ With two seconds of pre-roll, the clip itself therefore begins at FFmpeg
177
+ time two. ``absolute`` means the FFmpeg variable is sequence time and
178
+ ``absolute_clip_start`` identifies the clip's true start.
179
+ """
180
+
181
+ origin: TimeOrigin = "clip_local"
182
+ absolute_clip_start: Fraction | None = None
183
+ pre_roll: Fraction = Fraction(0)
184
+ post_roll: Fraction = Fraction(0)
185
+
186
+ def __post_init__(self) -> None:
187
+ if self.origin not in {"clip_local", "absolute"}:
188
+ raise AnimationClockError(
189
+ f"origin must be 'clip_local' or 'absolute', got {self.origin!r}"
190
+ )
191
+ pre_roll = _exact_time(self.pre_roll, name="pre_roll")
192
+ post_roll = _exact_time(self.post_roll, name="post_roll")
193
+ if pre_roll < 0 or post_roll < 0:
194
+ raise AnimationClockError("pre_roll and post_roll must be non-negative")
195
+ object.__setattr__(self, "pre_roll", pre_roll)
196
+ object.__setattr__(self, "post_roll", post_roll)
197
+
198
+ absolute_start = self.absolute_clip_start
199
+ if self.origin == "absolute":
200
+ if absolute_start is None:
201
+ raise AnimationClockError(
202
+ "absolute origin requires absolute_clip_start"
203
+ )
204
+ object.__setattr__(
205
+ self,
206
+ "absolute_clip_start",
207
+ _exact_time(absolute_start, name="absolute_clip_start"),
208
+ )
209
+ elif absolute_start is not None:
210
+ raise AnimationClockError(
211
+ "clip_local origin must not provide absolute_clip_start"
212
+ )
213
+
214
+ @property
215
+ def clip_start_in_input_time(self) -> Fraction:
216
+ """Return the FFmpeg time at which the actual clip begins."""
217
+
218
+ if self.origin == "clip_local":
219
+ return self.pre_roll
220
+ assert self.absolute_clip_start is not None
221
+ return self.absolute_clip_start
222
+
223
+ def input_window(self, clip_duration: Fraction) -> tuple[Fraction, Fraction]:
224
+ """Return the exact inclusive/exclusive render-window coordinates."""
225
+
226
+ duration = _exact_time(clip_duration, name="clip_duration")
227
+ if duration <= 0:
228
+ raise AnimationClockError("clip_duration must be positive")
229
+ if self.origin == "clip_local":
230
+ return Fraction(0), self.pre_roll + duration + self.post_roll
231
+ assert self.absolute_clip_start is not None
232
+ return (
233
+ self.absolute_clip_start - self.pre_roll,
234
+ self.absolute_clip_start + duration + self.post_roll,
235
+ )
236
+
237
+ def clip_time(self, input_time: Fraction, clip_duration: Fraction) -> Fraction:
238
+ """Convert one exact FFmpeg time to bounded clip-local time."""
239
+
240
+ exact_input = _exact_time(input_time, name="input_time")
241
+ duration = _exact_time(clip_duration, name="clip_duration")
242
+ local = exact_input - self.clip_start_in_input_time
243
+ return min(max(local, Fraction(0)), duration)
244
+
245
+ def state_at(
246
+ self,
247
+ input_time: Fraction,
248
+ retime_map: RetimeMap,
249
+ ) -> ClipTimeState:
250
+ """Resolve one shared source-time state, including transition handles.
251
+
252
+ Main callers:
253
+ - Expression-plan reference evaluators and per-frame command plans.
254
+ - Geometry integration for transform, corner pin, opacity, and Pan.
255
+
256
+ Why this exists:
257
+ - Each component previously combined FFmpeg time, pre-roll, and retime
258
+ offsets independently. Small differences made geometry drift during
259
+ reverse/freeze playback and overlapping transitions.
260
+ """
261
+
262
+ if not isinstance(retime_map, RetimeMap):
263
+ raise AnimationClockError("retime_map must be a RetimeMap")
264
+ exact_input = _exact_time(input_time, name="input_time")
265
+ duration = retime_map.timeline_duration
266
+ unclamped = exact_input - self.clip_start_in_input_time
267
+ if unclamped < 0:
268
+ phase: ClockPhase = "transition_pre_roll"
269
+ elif unclamped > duration:
270
+ phase = "transition_post_roll"
271
+ else:
272
+ phase = "clip"
273
+ clip_time = min(max(unclamped, Fraction(0)), duration)
274
+ track_time = retime_map.timeline_start + clip_time
275
+ sample = retime_map.sample(track_time)
276
+ return ClipTimeState(
277
+ input_time=exact_input,
278
+ unclamped_clip_time=unclamped,
279
+ clip_time=clip_time,
280
+ track_time=track_time,
281
+ source_time=sample.source_time,
282
+ phase=phase,
283
+ retime_segment_index=sample.segment_index,
284
+ retime_segment_kind=sample.segment_kind,
285
+ )
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class ScalarExpressionPlan:
290
+ """One scalar FFmpeg expression plus its exact reference evaluator."""
291
+
292
+ expression: str
293
+ track: TimelineAnimatedScalar
294
+ clock: AnimationClock
295
+ time_variable: str
296
+
297
+ @property
298
+ def clip_duration(self) -> Fraction:
299
+ return self.track.retime_map.timeline_duration
300
+
301
+ @property
302
+ def input_window_start(self) -> Fraction:
303
+ return self.clock.input_window(self.clip_duration)[0]
304
+
305
+ @property
306
+ def input_window_end(self) -> Fraction:
307
+ return self.clock.input_window(self.clip_duration)[1]
308
+
309
+ def value_at_input_time(self, input_time: Fraction) -> float:
310
+ """Evaluate the same clock and endpoint holds as the expression."""
311
+
312
+ state = self.clock.state_at(input_time, self.track.retime_map)
313
+ return self.track.source_track.value_at(state.source_time)
314
+
315
+
316
+ @dataclass(frozen=True)
317
+ class Vec2ExpressionPlan:
318
+ """Two FFmpeg expressions sharing one exact time/retime contract."""
319
+
320
+ x_expression: str
321
+ y_expression: str
322
+ track: TimelineAnimatedVec2
323
+ clock: AnimationClock
324
+ time_variable: str
325
+
326
+ @property
327
+ def clip_duration(self) -> Fraction:
328
+ return self.track.retime_map.timeline_duration
329
+
330
+ @property
331
+ def input_window_start(self) -> Fraction:
332
+ return self.clock.input_window(self.clip_duration)[0]
333
+
334
+ @property
335
+ def input_window_end(self) -> Fraction:
336
+ return self.clock.input_window(self.clip_duration)[1]
337
+
338
+ def value_at_input_time(self, input_time: Fraction) -> tuple[float, float]:
339
+ state = self.clock.state_at(input_time, self.track.retime_map)
340
+ return self.track.source_track.value_at(state.source_time)
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class ScalarFrameCommand:
345
+ """One exact per-frame scalar command for a command-only filter."""
346
+
347
+ frame_index: int
348
+ input_time: Fraction
349
+ clip_time: Fraction
350
+ track_time: Fraction
351
+ value: float
352
+
353
+
354
+ @dataclass(frozen=True)
355
+ class Vec2FrameCommand:
356
+ """One exact per-frame two-component command for a command-only filter."""
357
+
358
+ frame_index: int
359
+ input_time: Fraction
360
+ clip_time: Fraction
361
+ track_time: Fraction
362
+ value: tuple[float, float]
363
+
364
+
365
+ def _fraction_expression(value: Fraction) -> str:
366
+ if value.denominator == 1:
367
+ return str(value.numerator)
368
+ return f"({value.numerator}/{value.denominator})"
369
+
370
+
371
+ def _float_expression(value: float) -> str:
372
+ if not math.isfinite(value):
373
+ raise AnimationExecutionError("animation expression value must be finite")
374
+ if abs(value) < 5e-16:
375
+ value = 0.0
376
+ return format(value, ".17g")
377
+
378
+
379
+ def _validate_time_variable(value: object) -> str:
380
+ if value in {"t", "T"}:
381
+ return str(value)
382
+ if isinstance(value, str) and re.fullmatch(
383
+ r"\(on-1\)\*[1-9][0-9]*/[1-9][0-9]*",
384
+ value,
385
+ ):
386
+ # ``perspective=eval=frame`` exposes only its output frame number
387
+ # (``on``), not the ordinary video-filter time variable. FFmpeg defines
388
+ # that counter as one-based, so subtract one before multiplying by the
389
+ # exact frame duration. Accepting only this closed renderer-owned form
390
+ # keeps XML/user text out of FFmpeg expressions.
391
+ return f"({value})"
392
+ else:
393
+ raise AnimationExecutionError(
394
+ "time_variable must be 't' or 'T', or the renderer-owned exact "
395
+ "perspective clock '(on-1)*N/D'"
396
+ )
397
+
398
+
399
+ def _check_track_bounds(
400
+ *,
401
+ point_count: int,
402
+ segment_count: int,
403
+ limits: AnimationExpressionLimits,
404
+ ) -> None:
405
+ if point_count > limits.max_control_points:
406
+ raise AnimationExpressionLimitError(
407
+ f"animation has {point_count} control points; limit is "
408
+ f"{limits.max_control_points}"
409
+ )
410
+ if segment_count > limits.max_retime_segments:
411
+ raise AnimationExpressionLimitError(
412
+ f"animation has {segment_count} retime segments; limit is "
413
+ f"{limits.max_retime_segments}"
414
+ )
415
+ product = max(point_count - 1, 1) * segment_count
416
+ if product > limits.max_segment_point_product:
417
+ raise AnimationExpressionLimitError(
418
+ f"animation expression complexity is {product}; limit is "
419
+ f"{limits.max_segment_point_product}"
420
+ )
421
+
422
+
423
+ def _clip_time_expression(
424
+ *, time_variable: str, clock: AnimationClock, duration: Fraction
425
+ ) -> str:
426
+ start = _fraction_expression(clock.clip_start_in_input_time)
427
+ return (
428
+ f"clip(({time_variable})-({start}),0,{_fraction_expression(duration)})"
429
+ )
430
+
431
+
432
+ def _track_time_expression(
433
+ *, time_variable: str, clock: AnimationClock, timeline_start: Fraction,
434
+ duration: Fraction
435
+ ) -> str:
436
+ local = _clip_time_expression(
437
+ time_variable=time_variable,
438
+ clock=clock,
439
+ duration=duration,
440
+ )
441
+ return f"({_fraction_expression(timeline_start)}+({local}))"
442
+
443
+
444
+ def _source_time_expression(
445
+ track: TimelineAnimatedScalar | TimelineAnimatedVec2,
446
+ *,
447
+ time_variable: str,
448
+ clock: AnimationClock,
449
+ ) -> str:
450
+ """Map bounded FFmpeg time through every exact retime segment."""
451
+
452
+ retime_map = track.retime_map
453
+ timeline_time = _track_time_expression(
454
+ time_variable=time_variable,
455
+ clock=clock,
456
+ timeline_start=retime_map.timeline_start,
457
+ duration=retime_map.timeline_duration,
458
+ )
459
+
460
+ def mapped(segment_index: int) -> str:
461
+ segment = retime_map.segments[segment_index]
462
+ return (
463
+ f"({_fraction_expression(segment.source_start)}+"
464
+ f"(({timeline_time})-{_fraction_expression(segment.timeline_start)})*"
465
+ f"{_fraction_expression(segment.rate)})"
466
+ )
467
+
468
+ # The final segment owns the map's final endpoint. Every prior segment is
469
+ # half-open, exactly matching RetimeMap.map_timeline.
470
+ expression = mapped(len(retime_map.segments) - 1)
471
+ for index in reversed(range(len(retime_map.segments) - 1)):
472
+ boundary = _fraction_expression(retime_map.segments[index].timeline_end)
473
+ expression = (
474
+ f"if(lt(({timeline_time}),{boundary}),{mapped(index)},{expression})"
475
+ )
476
+ return expression
477
+
478
+
479
+ def _monotone_slopes(
480
+ points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
481
+ values: Sequence[float],
482
+ ) -> tuple[float, ...]:
483
+ """Mirror ``animation._monotone_slopes`` for expression coefficients.
484
+
485
+ Why this exists:
486
+ - The animation kernel intentionally keeps its interpolation helpers
487
+ private. This execution module freezes identical coefficients without
488
+ exposing an additional central API during isolated development.
489
+ """
490
+
491
+ count = len(points)
492
+ if count == 1:
493
+ return (0.0,)
494
+ widths = [
495
+ float(points[index + 1].time - points[index].time)
496
+ for index in range(count - 1)
497
+ ]
498
+ secants = [
499
+ (values[index + 1] - values[index]) / widths[index]
500
+ for index in range(count - 1)
501
+ ]
502
+ if count == 2:
503
+ return (secants[0], secants[0])
504
+
505
+ slopes = [0.0] * count
506
+ for index in range(1, count - 1):
507
+ left = secants[index - 1]
508
+ right = secants[index]
509
+ if left == 0.0 or right == 0.0 or left * right <= 0.0:
510
+ slopes[index] = 0.0
511
+ continue
512
+ left_weight = 2.0 * widths[index] + widths[index - 1]
513
+ right_weight = widths[index] + 2.0 * widths[index - 1]
514
+ slopes[index] = (left_weight + right_weight) / (
515
+ left_weight / left + right_weight / right
516
+ )
517
+
518
+ def endpoint(
519
+ adjacent_width: float,
520
+ next_width: float,
521
+ adjacent_secant: float,
522
+ next_secant: float,
523
+ ) -> float:
524
+ slope = (
525
+ (2.0 * adjacent_width + next_width) * adjacent_secant
526
+ - adjacent_width * next_secant
527
+ ) / (adjacent_width + next_width)
528
+ if slope * adjacent_secant <= 0.0:
529
+ return 0.0
530
+ if (
531
+ adjacent_secant * next_secant < 0.0
532
+ and abs(slope) > abs(3.0 * adjacent_secant)
533
+ ):
534
+ return 3.0 * adjacent_secant
535
+ return slope
536
+
537
+ slopes[0] = endpoint(widths[0], widths[1], secants[0], secants[1])
538
+ slopes[-1] = endpoint(widths[-1], widths[-2], secants[-1], secants[-2])
539
+ return tuple(slopes)
540
+
541
+
542
+ def _easing_expression(raw: str, interpolation: str) -> str:
543
+ if interpolation == "linear":
544
+ return raw
545
+ if interpolation == "ease":
546
+ return f"({raw})*({raw})*(3-2*({raw}))"
547
+ if interpolation == "ease-in":
548
+ return f"({raw})*({raw})*({raw})"
549
+ if interpolation == "ease-out":
550
+ return f"1-(1-({raw}))*(1-({raw}))*(1-({raw}))"
551
+ raise AnimationExecutionError(
552
+ f"unsupported animation interpolation {interpolation!r}"
553
+ )
554
+
555
+
556
+ def _component_expression(
557
+ *,
558
+ points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
559
+ values: Sequence[float],
560
+ source_time: str,
561
+ ) -> str:
562
+ if len(points) == 1:
563
+ return _float_expression(values[0])
564
+
565
+ slopes = _monotone_slopes(points, values)
566
+ segments: list[str] = []
567
+ for index, (left, right) in enumerate(zip(points, points[1:])):
568
+ raw = (
569
+ f"(({source_time})-{_fraction_expression(left.time)})/"
570
+ f"{_fraction_expression(right.time-left.time)}"
571
+ )
572
+ eased = _easing_expression(raw, right.interpolation)
573
+ if right.curve == "linear":
574
+ value = (
575
+ f"({_float_expression(values[index])}+"
576
+ f"({_float_expression(values[index+1]-values[index])})*({eased}))"
577
+ )
578
+ elif right.curve == "smooth":
579
+ squared = f"({eased})*({eased})"
580
+ cubed = f"({squared})*({eased})"
581
+ width = float(right.time - left.time)
582
+ value = (
583
+ f"((2*({cubed})-3*({squared})+1)*"
584
+ f"{_float_expression(values[index])}+"
585
+ f"(({cubed})-2*({squared})+({eased}))*"
586
+ f"{_float_expression(width*slopes[index])}+"
587
+ f"(-2*({cubed})+3*({squared}))*"
588
+ f"{_float_expression(values[index+1])}+"
589
+ f"(({cubed})-({squared}))*"
590
+ f"{_float_expression(width*slopes[index+1])})"
591
+ )
592
+ low = _float_expression(min(values[index], values[index + 1]))
593
+ high = _float_expression(max(values[index], values[index + 1]))
594
+ value = f"clip({value},{low},{high})"
595
+ else:
596
+ raise AnimationExecutionError(
597
+ f"unsupported animation value curve {right.curve!r}"
598
+ )
599
+ segments.append(value)
600
+
601
+ # Explicit endpoint holds are based on source time, after the timeline has
602
+ # already been clamped for transition pre/post-roll.
603
+ expression = _float_expression(values[-1])
604
+ for index in reversed(range(len(segments))):
605
+ expression = (
606
+ f"if(lte(({source_time}),{_fraction_expression(points[index+1].time)}),"
607
+ f"{segments[index]},{expression})"
608
+ )
609
+ return (
610
+ f"if(lte(({source_time}),{_fraction_expression(points[0].time)}),"
611
+ f"{_float_expression(values[0])},{expression})"
612
+ )
613
+
614
+
615
+ def _enforce_expression_size(
616
+ expression: str,
617
+ *,
618
+ limits: AnimationExpressionLimits,
619
+ component: str,
620
+ ) -> None:
621
+ if len(expression) > limits.max_expression_chars:
622
+ raise AnimationExpressionLimitError(
623
+ f"{component} expression has {len(expression)} characters; limit is "
624
+ f"{limits.max_expression_chars}"
625
+ )
626
+
627
+
628
+ def compile_scalar_expression(
629
+ track: TimelineAnimatedScalar,
630
+ *,
631
+ clock: AnimationClock = AnimationClock(),
632
+ time_variable: str = "t",
633
+ limits: AnimationExpressionLimits = AnimationExpressionLimits(),
634
+ ) -> ScalarExpressionPlan:
635
+ """Compile a scalar track without flattening its retime map.
636
+
637
+ Main callers:
638
+ - Geometry, opacity, and audio filter builders during central integration.
639
+ """
640
+
641
+ if not isinstance(track, TimelineAnimatedScalar):
642
+ raise AnimationExecutionError("track must be TimelineAnimatedScalar")
643
+ if not isinstance(clock, AnimationClock):
644
+ raise AnimationClockError("clock must be AnimationClock")
645
+ if not isinstance(limits, AnimationExpressionLimits):
646
+ raise AnimationExpressionLimitError(
647
+ "limits must be AnimationExpressionLimits"
648
+ )
649
+ variable = _validate_time_variable(time_variable)
650
+ points = track.source_track.control_points
651
+ _check_track_bounds(
652
+ point_count=len(points),
653
+ segment_count=len(track.retime_map.segments),
654
+ limits=limits,
655
+ )
656
+ source_time = _source_time_expression(
657
+ track,
658
+ time_variable=variable,
659
+ clock=clock,
660
+ )
661
+ expression = _component_expression(
662
+ points=points,
663
+ values=tuple(point.value for point in points),
664
+ source_time=source_time,
665
+ )
666
+ _enforce_expression_size(expression, limits=limits, component="scalar")
667
+ return ScalarExpressionPlan(expression, track, clock, variable)
668
+
669
+
670
+ def compile_vec2_expressions(
671
+ track: TimelineAnimatedVec2,
672
+ *,
673
+ clock: AnimationClock = AnimationClock(),
674
+ time_variable: str = "t",
675
+ limits: AnimationExpressionLimits = AnimationExpressionLimits(),
676
+ ) -> Vec2ExpressionPlan:
677
+ """Compile each vector component against one shared exact retime map."""
678
+
679
+ if not isinstance(track, TimelineAnimatedVec2):
680
+ raise AnimationExecutionError("track must be TimelineAnimatedVec2")
681
+ if not isinstance(clock, AnimationClock):
682
+ raise AnimationClockError("clock must be AnimationClock")
683
+ if not isinstance(limits, AnimationExpressionLimits):
684
+ raise AnimationExpressionLimitError(
685
+ "limits must be AnimationExpressionLimits"
686
+ )
687
+ variable = _validate_time_variable(time_variable)
688
+ points = track.source_track.control_points
689
+ _check_track_bounds(
690
+ point_count=len(points),
691
+ segment_count=len(track.retime_map.segments),
692
+ limits=limits,
693
+ )
694
+ source_time = _source_time_expression(
695
+ track,
696
+ time_variable=variable,
697
+ clock=clock,
698
+ )
699
+ x_expression = _component_expression(
700
+ points=points,
701
+ values=tuple(point.value[0] for point in points),
702
+ source_time=source_time,
703
+ )
704
+ y_expression = _component_expression(
705
+ points=points,
706
+ values=tuple(point.value[1] for point in points),
707
+ source_time=source_time,
708
+ )
709
+ _enforce_expression_size(x_expression, limits=limits, component="vec2 x")
710
+ _enforce_expression_size(y_expression, limits=limits, component="vec2 y")
711
+ return Vec2ExpressionPlan(x_expression, y_expression, track, clock, variable)
712
+
713
+
714
+ def _frame_times(
715
+ *,
716
+ window_start: Fraction,
717
+ frame_rate: Fraction,
718
+ frame_count: int,
719
+ first_frame: int,
720
+ ) -> tuple[tuple[int, Fraction], ...]:
721
+ rate = _exact_time(frame_rate, name="frame_rate")
722
+ if rate <= 0:
723
+ raise AnimationClockError("frame_rate must be positive")
724
+ count = _positive_int(frame_count, name="frame_count")
725
+ if isinstance(first_frame, bool) or not isinstance(first_frame, int):
726
+ raise AnimationClockError("first_frame must be an integer")
727
+ if first_frame < 0:
728
+ raise AnimationClockError("first_frame must be non-negative")
729
+ return tuple(
730
+ (
731
+ frame_index,
732
+ window_start + Fraction(frame_index, 1) / rate,
733
+ )
734
+ for frame_index in range(first_frame, first_frame + count)
735
+ )
736
+
737
+
738
+ def sample_scalar_frames(
739
+ plan: ScalarExpressionPlan,
740
+ *,
741
+ frame_rate: Fraction,
742
+ frame_count: int,
743
+ first_frame: int = 0,
744
+ ) -> tuple[ScalarFrameCommand, ...]:
745
+ """Build deterministic scalar commands at exact output-frame times."""
746
+
747
+ if not isinstance(plan, ScalarExpressionPlan):
748
+ raise AnimationExecutionError("plan must be ScalarExpressionPlan")
749
+ commands: list[ScalarFrameCommand] = []
750
+ for frame_index, input_time in _frame_times(
751
+ window_start=plan.input_window_start,
752
+ frame_rate=frame_rate,
753
+ frame_count=frame_count,
754
+ first_frame=first_frame,
755
+ ):
756
+ state = plan.clock.state_at(input_time, plan.track.retime_map)
757
+ commands.append(
758
+ ScalarFrameCommand(
759
+ frame_index=frame_index,
760
+ input_time=input_time,
761
+ clip_time=state.clip_time,
762
+ track_time=state.track_time,
763
+ value=plan.track.source_track.value_at(state.source_time),
764
+ )
765
+ )
766
+ return tuple(commands)
767
+
768
+
769
+ def sample_vec2_frames(
770
+ plan: Vec2ExpressionPlan,
771
+ *,
772
+ frame_rate: Fraction,
773
+ frame_count: int,
774
+ first_frame: int = 0,
775
+ ) -> tuple[Vec2FrameCommand, ...]:
776
+ """Build deterministic vector commands at exact output-frame times."""
777
+
778
+ if not isinstance(plan, Vec2ExpressionPlan):
779
+ raise AnimationExecutionError("plan must be Vec2ExpressionPlan")
780
+ commands: list[Vec2FrameCommand] = []
781
+ for frame_index, input_time in _frame_times(
782
+ window_start=plan.input_window_start,
783
+ frame_rate=frame_rate,
784
+ frame_count=frame_count,
785
+ first_frame=first_frame,
786
+ ):
787
+ state = plan.clock.state_at(input_time, plan.track.retime_map)
788
+ commands.append(
789
+ Vec2FrameCommand(
790
+ frame_index=frame_index,
791
+ input_time=input_time,
792
+ clip_time=state.clip_time,
793
+ track_time=state.track_time,
794
+ value=plan.track.source_track.value_at(state.source_time),
795
+ )
796
+ )
797
+ return tuple(commands)
798
+
799
+
800
+ __all__ = [
801
+ "AnimationClock",
802
+ "AnimationClockError",
803
+ "AnimationExecutionError",
804
+ "AnimationExpressionLimitError",
805
+ "AnimationExpressionLimits",
806
+ "ClipTimeState",
807
+ "ClockPhase",
808
+ "ScalarExpressionPlan",
809
+ "ScalarFrameCommand",
810
+ "TimeOrigin",
811
+ "Vec2ExpressionPlan",
812
+ "Vec2FrameCommand",
813
+ "compile_scalar_expression",
814
+ "compile_vec2_expressions",
815
+ "sample_scalar_frames",
816
+ "sample_vec2_frames",
817
+ ]