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,893 @@
1
+ """Typed, deterministic animation tracks for the experimental renderer IR.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ ``model.Keyframe`` / ``model.Parameter``
7
+ Parser-owned strings and exact keyframe times enter through explicit
8
+ ``from_keyframes`` and ``from_parameter`` adapters.
9
+
10
+ ``ScalarControlPoint`` / ``Vec2ControlPoint``
11
+ Validate the value shape, interpolation names, exact ``Fraction`` time,
12
+ and preserved ``auxValue`` tangent metadata.
13
+
14
+ ``AnimatedScalar`` / ``AnimatedVec2``
15
+ Evaluate source-time animation. Values hold outside the keyframe range;
16
+ the interval ending at a keyframe uses that destination keyframe's
17
+ ``interp`` and ``curve`` settings.
18
+
19
+ ``TimelineAnimatedScalar`` / ``TimelineAnimatedVec2``
20
+ Evaluate a source-time track on the output timeline by first applying a
21
+ ``RetimeMap``. This keeps reverse, freeze, variable-rate, and repeated
22
+ source occurrences exact without rewriting their easing curves.
23
+
24
+ ``ken_burns_progress`` / ``ken_burns_progress_expression``
25
+ Share the measured Final Cut Pan-camera progress curve between numeric
26
+ diagnostics and FFmpeg lowering. All four camera edges must consume this
27
+ one value; independent edge clocks would warp the camera rectangle.
28
+
29
+ Important invariants
30
+ --------------------
31
+
32
+ * Control-point times are exact ``fractions.Fraction`` values. Floats are
33
+ rejected at the timing boundary.
34
+ * Control points are strictly ordered and duplicate times are invalid.
35
+ * ``smooth`` value curves use monotone cubic interpolation independently per
36
+ component, so a segment cannot overshoot either endpoint.
37
+ * ``auxValue`` is preserved and reported as uncalibrated. It never silently
38
+ changes the evaluated curve.
39
+ * Lossless retimed evaluation is the primary API. Materializing ordinary
40
+ timeline points is intentionally limited to one forward linear retime
41
+ segment; reverse, freeze, and multi-segment maps fail explicitly.
42
+
43
+ Why this exists
44
+ ---------------
45
+
46
+ Final Cut stores genuine animation inside ``keyframeAnimation`` and may place
47
+ those source-local keyframes under retimed clips. The old renderer reduced
48
+ animation to ad-hoc FFmpeg expressions and could silently treat a reverse or
49
+ repeated source range as forward playback. This module freezes a typed,
50
+ testable animation contract before the shared compiler and FFmpeg builder are
51
+ changed.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from dataclasses import dataclass
57
+ from fractions import Fraction
58
+ import math
59
+ from typing import Iterable, Literal, Sequence, TypeAlias
60
+
61
+ from .model import Keyframe, Parameter
62
+ from .retime import RetimeMap, RetimeSegment
63
+
64
+
65
+ Interpolation: TypeAlias = Literal["linear", "ease", "ease-in", "ease-out"]
66
+ ValueCurve: TypeAlias = Literal["linear", "smooth"]
67
+ Vec2: TypeAlias = tuple[float, float]
68
+ AnimationValue: TypeAlias = float | Vec2
69
+
70
+
71
+ # Final Cut Pan progress measured from the BT.709 1080p Stage 5 landmark oracle.
72
+ # Each point is a normalized output time and the corresponding normalized
73
+ # camera-window progress recovered from robust source-to-output affine scale.
74
+ # The same monotone curve drives all four rect edges.
75
+ # Piecewise interpolation avoids fitting an attractive but measurably wrong
76
+ # stock easing curve; private reference movies remain in the evidence tree.
77
+ KEN_BURNS_PROGRESS_CALIBRATION_ID = "xyzt-20260812-fcp-pan-trajectory-1080p-v3"
78
+ KEN_BURNS_PROGRESS_MAX_RESIDUAL = 0.0015
79
+ KEN_BURNS_PROGRESS_POINTS: tuple[tuple[float, float], ...] = (
80
+ (0.0, 0.0),
81
+ (0.050847, 0.007683),
82
+ (0.101695, 0.027583),
83
+ (0.152542, 0.061088),
84
+ (0.203390, 0.107125),
85
+ (0.254237, 0.167228),
86
+ (0.305085, 0.233681),
87
+ (0.355932, 0.300808),
88
+ (0.406780, 0.367519),
89
+ (0.457627, 0.435341),
90
+ (0.508475, 0.501657),
91
+ (0.559322, 0.567544),
92
+ (0.610169, 0.634175),
93
+ (0.661017, 0.700629),
94
+ (0.711864, 0.767585),
95
+ (0.762712, 0.834247),
96
+ (0.813559, 0.893769),
97
+ (0.864407, 0.940151),
98
+ (0.915254, 0.974251),
99
+ (0.966102, 0.993884),
100
+ (1.0, 1.0),
101
+ )
102
+
103
+
104
+ class AnimationError(ValueError):
105
+ """Base error for invalid or unsupported animation contracts."""
106
+
107
+
108
+ class AnimationValidationError(AnimationError):
109
+ """A source track is malformed and cannot be evaluated deterministically."""
110
+
111
+
112
+ class UnsupportedAnimationMappingError(AnimationError):
113
+ """A requested lossy timeline-point conversion would be ambiguous."""
114
+
115
+
116
+ def _exact_time(value: object, *, field_name: str) -> Fraction:
117
+ """Accept only exact rational animation times.
118
+
119
+ Main callers:
120
+ - Control-point construction.
121
+ - Public ``value_at`` methods.
122
+
123
+ Why this exists:
124
+ - ``Fraction(0.1)`` preserves a binary floating-point approximation, not
125
+ the exact FCPXML rational time. Timing floats therefore fail here.
126
+ """
127
+
128
+ if isinstance(value, bool):
129
+ raise AnimationValidationError(
130
+ f"{field_name} must be an exact Fraction, not bool"
131
+ )
132
+ if isinstance(value, Fraction):
133
+ return value
134
+ if isinstance(value, int):
135
+ return Fraction(value)
136
+ if isinstance(value, float):
137
+ if not math.isfinite(value):
138
+ raise AnimationValidationError(f"{field_name} must be finite")
139
+ raise AnimationValidationError(
140
+ f"{field_name} must be an exact Fraction, not float"
141
+ )
142
+ raise AnimationValidationError(
143
+ f"{field_name} must be an exact Fraction, got {type(value).__name__}"
144
+ )
145
+
146
+
147
+ def _finite_number(value: object, *, field_name: str) -> float:
148
+ if isinstance(value, bool):
149
+ raise AnimationValidationError(
150
+ f"{field_name} must be a finite number, not bool"
151
+ )
152
+ try:
153
+ result = float(value)
154
+ except (TypeError, ValueError) as exc:
155
+ raise AnimationValidationError(f"{field_name} must be a finite number") from exc
156
+ if not math.isfinite(result):
157
+ raise AnimationValidationError(f"{field_name} must be finite")
158
+ return result
159
+
160
+
161
+ def _normalize_interpolation(raw: str | None) -> Interpolation:
162
+ compact = (raw or "linear").strip().lower().replace("-", "").replace("_", "")
163
+ values: dict[str, Interpolation] = {
164
+ "linear": "linear",
165
+ "ease": "ease",
166
+ "easein": "ease-in",
167
+ "easeout": "ease-out",
168
+ }
169
+ try:
170
+ return values[compact]
171
+ except KeyError as exc:
172
+ raise AnimationValidationError(
173
+ f"unsupported keyframe interpolation {raw!r}"
174
+ ) from exc
175
+
176
+
177
+ def _normalize_curve(raw: str | None) -> ValueCurve:
178
+ # FCPXML 1.14 declares ``smooth`` as the DTD default.
179
+ compact = (raw or "smooth").strip().lower()
180
+ if compact == "linear":
181
+ return "linear"
182
+ if compact == "smooth":
183
+ return "smooth"
184
+ raise AnimationValidationError(f"unsupported keyframe curve {raw!r}")
185
+
186
+
187
+ def _parse_components(raw: str, *, count: int, field_name: str) -> tuple[float, ...]:
188
+ parts = raw.replace(",", " ").split()
189
+ if len(parts) != count:
190
+ raise AnimationValidationError(
191
+ f"{field_name} requires exactly {count} numeric component(s), got {raw!r}"
192
+ )
193
+ return tuple(
194
+ _finite_number(part, field_name=f"{field_name} component {index}")
195
+ for index, part in enumerate(parts)
196
+ )
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class ScalarControlPoint:
201
+ """One validated scalar keyframe in source-local time."""
202
+
203
+ time: Fraction
204
+ value: float
205
+ interpolation: Interpolation = "linear"
206
+ curve: ValueCurve = "smooth"
207
+ aux_value: str | None = None
208
+
209
+ def __post_init__(self) -> None:
210
+ object.__setattr__(
211
+ self, "time", _exact_time(self.time, field_name="control-point time")
212
+ )
213
+ object.__setattr__(
214
+ self, "value", _finite_number(self.value, field_name="scalar value")
215
+ )
216
+ object.__setattr__(
217
+ self, "interpolation", _normalize_interpolation(self.interpolation)
218
+ )
219
+ object.__setattr__(self, "curve", _normalize_curve(self.curve))
220
+ if self.aux_value is not None and not isinstance(self.aux_value, str):
221
+ raise AnimationValidationError("auxValue must be a string or None")
222
+
223
+ @classmethod
224
+ def from_keyframe(cls, keyframe: Keyframe) -> "ScalarControlPoint":
225
+ """Parse one parser-owned keyframe as a typed scalar point."""
226
+
227
+ if not isinstance(keyframe, Keyframe):
228
+ raise AnimationValidationError(
229
+ "scalar control point requires model.Keyframe"
230
+ )
231
+ value = _parse_components(
232
+ keyframe.value, count=1, field_name="scalar keyframe value"
233
+ )[0]
234
+ return cls(
235
+ time=keyframe.time,
236
+ value=value,
237
+ interpolation=_normalize_interpolation(keyframe.interp),
238
+ curve=_normalize_curve(keyframe.curve),
239
+ aux_value=keyframe.aux_value,
240
+ )
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class Vec2ControlPoint:
245
+ """One validated two-component keyframe in source-local time."""
246
+
247
+ time: Fraction
248
+ value: Vec2
249
+ interpolation: Interpolation = "linear"
250
+ curve: ValueCurve = "smooth"
251
+ aux_value: str | None = None
252
+
253
+ def __post_init__(self) -> None:
254
+ object.__setattr__(
255
+ self, "time", _exact_time(self.time, field_name="control-point time")
256
+ )
257
+ if not isinstance(self.value, tuple) or len(self.value) != 2:
258
+ raise AnimationValidationError(
259
+ "vec2 value must contain exactly two components"
260
+ )
261
+ object.__setattr__(
262
+ self,
263
+ "value",
264
+ (
265
+ _finite_number(self.value[0], field_name="vec2 x"),
266
+ _finite_number(self.value[1], field_name="vec2 y"),
267
+ ),
268
+ )
269
+ object.__setattr__(
270
+ self, "interpolation", _normalize_interpolation(self.interpolation)
271
+ )
272
+ object.__setattr__(self, "curve", _normalize_curve(self.curve))
273
+ if self.aux_value is not None and not isinstance(self.aux_value, str):
274
+ raise AnimationValidationError("auxValue must be a string or None")
275
+
276
+ @classmethod
277
+ def from_keyframe(cls, keyframe: Keyframe) -> "Vec2ControlPoint":
278
+ """Parse one parser-owned keyframe as a typed two-component point."""
279
+
280
+ if not isinstance(keyframe, Keyframe):
281
+ raise AnimationValidationError("vec2 control point requires model.Keyframe")
282
+ values = _parse_components(
283
+ keyframe.value, count=2, field_name="vec2 keyframe value"
284
+ )
285
+ return cls(
286
+ time=keyframe.time,
287
+ value=(values[0], values[1]),
288
+ interpolation=_normalize_interpolation(keyframe.interp),
289
+ curve=_normalize_curve(keyframe.curve),
290
+ aux_value=keyframe.aux_value,
291
+ )
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class AnimationNotice:
296
+ """A preserved animation fact that is not yet calibrated for execution."""
297
+
298
+ code: Literal["uncalibrated_aux_value"]
299
+ control_point_index: int
300
+ time: Fraction
301
+ detail: str
302
+
303
+
304
+ @dataclass(frozen=True)
305
+ class MappedControlPoint:
306
+ """One source keyframe occurrence on the output timeline.
307
+
308
+ A moving occurrence has equal start/end times. A freeze occurrence spans
309
+ an interval and deliberately cannot be flattened to an arbitrary point.
310
+ """
311
+
312
+ source_index: int
313
+ source_time: Fraction
314
+ value: AnimationValue
315
+ interpolation: Interpolation
316
+ curve: ValueCurve
317
+ aux_value: str | None
318
+ timeline_start: Fraction
319
+ timeline_end: Fraction
320
+ includes_start: bool
321
+ includes_end: bool
322
+ segment_indices: tuple[int, ...]
323
+
324
+ @property
325
+ def is_interval(self) -> bool:
326
+ return self.timeline_end > self.timeline_start
327
+
328
+
329
+ def _validate_points(
330
+ points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
331
+ *,
332
+ expected_type: type[ScalarControlPoint] | type[Vec2ControlPoint],
333
+ ) -> None:
334
+ if not points:
335
+ raise AnimationValidationError(
336
+ "animation track requires at least one control point"
337
+ )
338
+ previous: Fraction | None = None
339
+ for index, point in enumerate(points):
340
+ if not isinstance(point, expected_type):
341
+ raise AnimationValidationError(
342
+ f"control_points[{index}] must be {expected_type.__name__}"
343
+ )
344
+ if previous is not None and point.time == previous:
345
+ raise AnimationValidationError(f"duplicate control-point time {point.time}")
346
+ if previous is not None and point.time < previous:
347
+ raise AnimationValidationError(
348
+ "control-point times must be strictly increasing"
349
+ )
350
+ previous = point.time
351
+
352
+
353
+ def _notices(
354
+ points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
355
+ ) -> tuple[AnimationNotice, ...]:
356
+ return tuple(
357
+ AnimationNotice(
358
+ code="uncalibrated_aux_value",
359
+ control_point_index=index,
360
+ time=point.time,
361
+ detail=f"auxValue {point.aux_value!r} is preserved but not applied",
362
+ )
363
+ for index, point in enumerate(points)
364
+ if point.aux_value is not None
365
+ )
366
+
367
+
368
+ def _eased_fraction(value: float, interpolation: Interpolation) -> float:
369
+ if interpolation == "linear":
370
+ return value
371
+ if interpolation == "ease":
372
+ return value * value * (3.0 - 2.0 * value)
373
+ if interpolation == "ease-in":
374
+ return value * value * value
375
+ if interpolation == "ease-out":
376
+ inverse = 1.0 - value
377
+ return 1.0 - inverse * inverse * inverse
378
+ raise AssertionError(f"unreachable interpolation {interpolation!r}")
379
+
380
+
381
+ def ken_burns_progress(
382
+ elapsed: Fraction,
383
+ duration: Fraction,
384
+ ) -> float:
385
+ """Return Final Cut's calibrated, endpoint-held Pan camera progress.
386
+
387
+ Main callers:
388
+ - Geometry snapshots for Crop mode ``pan``.
389
+ - Ken Burns oracle diagnostics.
390
+
391
+ Why this exists:
392
+ - The previous geometry snapshot used linear time while the FFmpeg path
393
+ constructed a separate inline counter. The shared piecewise-linear
394
+ calibration below follows the measured Final Cut marker trajectory.
395
+ Exact rational time is retained until the final normalized scalar is
396
+ evaluated.
397
+ """
398
+
399
+ exact_elapsed = _exact_time(elapsed, field_name="Ken Burns elapsed time")
400
+ exact_duration = _exact_time(duration, field_name="Ken Burns duration")
401
+ if exact_duration <= 0:
402
+ raise AnimationValidationError("Ken Burns duration must be positive")
403
+ normalized = min(max(exact_elapsed / exact_duration, Fraction(0)), Fraction(1))
404
+ value = float(normalized)
405
+ for (left_time, left_value), (right_time, right_value) in zip(
406
+ KEN_BURNS_PROGRESS_POINTS[:-1],
407
+ KEN_BURNS_PROGRESS_POINTS[1:],
408
+ strict=True,
409
+ ):
410
+ if value <= right_time:
411
+ fraction = (value - left_time) / (right_time - left_time)
412
+ return left_value + (right_value - left_value) * fraction
413
+ return 1.0
414
+
415
+
416
+ def ken_burns_progress_expression(
417
+ *,
418
+ counter: str,
419
+ frame_count: int,
420
+ first_frame_index: int = 0,
421
+ ) -> str:
422
+ """Return the FFmpeg expression matching :func:`ken_burns_progress`.
423
+
424
+ ``zoompan`` exposes the zero-based renderer-owned ``on`` counter. The
425
+ final visible frame is therefore ``frame_count - 1`` and must evaluate to
426
+ exactly one. XML text is never accepted as the counter expression.
427
+ """
428
+
429
+ if counter not in {"on", "N"}:
430
+ raise AnimationValidationError(
431
+ "Ken Burns FFmpeg counter must be the renderer-owned 'on' or 'N' variable"
432
+ )
433
+ if isinstance(frame_count, bool) or not isinstance(frame_count, int):
434
+ raise AnimationValidationError("Ken Burns frame_count must be an integer")
435
+ if frame_count < 2:
436
+ raise AnimationValidationError("Ken Burns frame_count must be at least two")
437
+ if first_frame_index not in {0, 1}:
438
+ raise AnimationValidationError(
439
+ "Ken Burns first_frame_index must be zero or one"
440
+ )
441
+ elapsed = counter if first_frame_index == 0 else f"({counter}-1)"
442
+ normalized = f"min(max({elapsed}/{frame_count - 1},0),1)"
443
+
444
+ def number(value: float) -> str:
445
+ return format(value, ".12g")
446
+
447
+ expression = "1"
448
+ for (left_time, left_value), (right_time, right_value) in reversed(
449
+ tuple(
450
+ zip(
451
+ KEN_BURNS_PROGRESS_POINTS[:-1],
452
+ KEN_BURNS_PROGRESS_POINTS[1:],
453
+ strict=True,
454
+ )
455
+ )
456
+ ):
457
+ segment = (
458
+ f"({number(left_value)}+({number(right_value-left_value)})*"
459
+ f"((({normalized})-{number(left_time)})/"
460
+ f"{number(right_time-left_time)}))"
461
+ )
462
+ expression = (
463
+ f"if(lte(({normalized}),{number(right_time)}),"
464
+ f"{segment},{expression})"
465
+ )
466
+ return expression
467
+
468
+
469
+ def _monotone_slopes(
470
+ times: Sequence[Fraction], values: Sequence[float]
471
+ ) -> tuple[float, ...]:
472
+ """Return PCHIP/Fritsch-Carlson slopes without endpoint overshoot."""
473
+
474
+ count = len(times)
475
+ if count == 1:
476
+ return (0.0,)
477
+ widths = [float(times[index + 1] - times[index]) for index in range(count - 1)]
478
+ secants = [
479
+ (values[index + 1] - values[index]) / widths[index]
480
+ for index in range(count - 1)
481
+ ]
482
+ if count == 2:
483
+ return (secants[0], secants[0])
484
+
485
+ slopes = [0.0] * count
486
+ for index in range(1, count - 1):
487
+ left = secants[index - 1]
488
+ right = secants[index]
489
+ if left == 0.0 or right == 0.0 or left * right <= 0.0:
490
+ slopes[index] = 0.0
491
+ continue
492
+ left_weight = 2.0 * widths[index] + widths[index - 1]
493
+ right_weight = widths[index] + 2.0 * widths[index - 1]
494
+ slopes[index] = (left_weight + right_weight) / (
495
+ left_weight / left + right_weight / right
496
+ )
497
+
498
+ slopes[0] = _endpoint_slope(widths[0], widths[1], secants[0], secants[1])
499
+ slopes[-1] = _endpoint_slope(widths[-1], widths[-2], secants[-1], secants[-2])
500
+ return tuple(slopes)
501
+
502
+
503
+ def _endpoint_slope(
504
+ adjacent_width: float,
505
+ next_width: float,
506
+ adjacent_secant: float,
507
+ next_secant: float,
508
+ ) -> float:
509
+ slope = (
510
+ (2.0 * adjacent_width + next_width) * adjacent_secant
511
+ - adjacent_width * next_secant
512
+ ) / (adjacent_width + next_width)
513
+ if slope * adjacent_secant <= 0.0:
514
+ return 0.0
515
+ if adjacent_secant * next_secant < 0.0 and abs(slope) > abs(3.0 * adjacent_secant):
516
+ return 3.0 * adjacent_secant
517
+ return slope
518
+
519
+
520
+ def _interpolate_component(
521
+ *,
522
+ index: int,
523
+ fraction: float,
524
+ times: Sequence[Fraction],
525
+ values: Sequence[float],
526
+ curve: ValueCurve,
527
+ slopes: Sequence[float],
528
+ ) -> float:
529
+ left = values[index]
530
+ right = values[index + 1]
531
+ if curve == "linear":
532
+ return left + (right - left) * fraction
533
+
534
+ width = float(times[index + 1] - times[index])
535
+ squared = fraction * fraction
536
+ cubed = squared * fraction
537
+ left_basis = 2.0 * cubed - 3.0 * squared + 1.0
538
+ left_slope_basis = cubed - 2.0 * squared + fraction
539
+ right_basis = -2.0 * cubed + 3.0 * squared
540
+ right_slope_basis = cubed - squared
541
+ result = (
542
+ left_basis * left
543
+ + left_slope_basis * width * slopes[index]
544
+ + right_basis * right
545
+ + right_slope_basis * width * slopes[index + 1]
546
+ )
547
+ # Floating-point roundoff must not violate the monotonicity guarantee.
548
+ return min(max(result, min(left, right)), max(left, right))
549
+
550
+
551
+ def _segment_index(
552
+ points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
553
+ time: Fraction,
554
+ ) -> int:
555
+ for index in range(len(points) - 1):
556
+ if points[index].time <= time <= points[index + 1].time:
557
+ return index
558
+ raise AssertionError("time inside track range did not resolve to a segment")
559
+
560
+
561
+ @dataclass(frozen=True)
562
+ class AnimatedScalar:
563
+ """A validated scalar animation evaluated in source-local time.
564
+
565
+ Main callers:
566
+ - Geometry, opacity, effect, and audio compilers adapt parser parameters
567
+ through ``from_parameter`` or ``from_keyframes``.
568
+ - ``TimelineAnimatedScalar`` evaluates this track through a ``RetimeMap``.
569
+ """
570
+
571
+ control_points: tuple[ScalarControlPoint, ...]
572
+
573
+ def __post_init__(self) -> None:
574
+ points = tuple(self.control_points)
575
+ object.__setattr__(self, "control_points", points)
576
+ _validate_points(points, expected_type=ScalarControlPoint)
577
+
578
+ @classmethod
579
+ def from_keyframes(cls, keyframes: Iterable[Keyframe]) -> "AnimatedScalar":
580
+ return cls(tuple(ScalarControlPoint.from_keyframe(item) for item in keyframes))
581
+
582
+ @classmethod
583
+ def from_parameter(cls, parameter: Parameter) -> "AnimatedScalar":
584
+ if not isinstance(parameter, Parameter):
585
+ raise AnimationValidationError("scalar animation requires model.Parameter")
586
+ if not parameter.keyframes:
587
+ raise AnimationValidationError(
588
+ "parameter has no keyframeAnimation control points"
589
+ )
590
+ return cls.from_keyframes(parameter.keyframes)
591
+
592
+ @property
593
+ def notices(self) -> tuple[AnimationNotice, ...]:
594
+ return _notices(self.control_points)
595
+
596
+ def value_at(self, source_time: Fraction) -> float:
597
+ exact_time = _exact_time(source_time, field_name="source animation time")
598
+ points = self.control_points
599
+ if exact_time <= points[0].time:
600
+ return points[0].value
601
+ if exact_time >= points[-1].time:
602
+ return points[-1].value
603
+
604
+ index = _segment_index(points, exact_time)
605
+ left = points[index]
606
+ right = points[index + 1]
607
+ raw_fraction = float((exact_time - left.time) / (right.time - left.time))
608
+ fraction = _eased_fraction(raw_fraction, right.interpolation)
609
+ times = tuple(point.time for point in points)
610
+ values = tuple(point.value for point in points)
611
+ slopes = _monotone_slopes(times, values)
612
+ return _interpolate_component(
613
+ index=index,
614
+ fraction=fraction,
615
+ times=times,
616
+ values=values,
617
+ curve=right.curve,
618
+ slopes=slopes,
619
+ )
620
+
621
+
622
+ @dataclass(frozen=True)
623
+ class AnimatedVec2:
624
+ """A validated two-component animation evaluated in source-local time."""
625
+
626
+ control_points: tuple[Vec2ControlPoint, ...]
627
+
628
+ def __post_init__(self) -> None:
629
+ points = tuple(self.control_points)
630
+ object.__setattr__(self, "control_points", points)
631
+ _validate_points(points, expected_type=Vec2ControlPoint)
632
+
633
+ @classmethod
634
+ def from_keyframes(cls, keyframes: Iterable[Keyframe]) -> "AnimatedVec2":
635
+ return cls(tuple(Vec2ControlPoint.from_keyframe(item) for item in keyframes))
636
+
637
+ @classmethod
638
+ def from_parameter(cls, parameter: Parameter) -> "AnimatedVec2":
639
+ if not isinstance(parameter, Parameter):
640
+ raise AnimationValidationError("vec2 animation requires model.Parameter")
641
+ if not parameter.keyframes:
642
+ raise AnimationValidationError(
643
+ "parameter has no keyframeAnimation control points"
644
+ )
645
+ return cls.from_keyframes(parameter.keyframes)
646
+
647
+ @property
648
+ def notices(self) -> tuple[AnimationNotice, ...]:
649
+ return _notices(self.control_points)
650
+
651
+ def value_at(self, source_time: Fraction) -> Vec2:
652
+ exact_time = _exact_time(source_time, field_name="source animation time")
653
+ points = self.control_points
654
+ if exact_time <= points[0].time:
655
+ return points[0].value
656
+ if exact_time >= points[-1].time:
657
+ return points[-1].value
658
+
659
+ index = _segment_index(points, exact_time)
660
+ left = points[index]
661
+ right = points[index + 1]
662
+ raw_fraction = float((exact_time - left.time) / (right.time - left.time))
663
+ fraction = _eased_fraction(raw_fraction, right.interpolation)
664
+ times = tuple(point.time for point in points)
665
+ components = tuple(zip(*(point.value for point in points)))
666
+ result: list[float] = []
667
+ for values in components:
668
+ slopes = _monotone_slopes(times, values)
669
+ result.append(
670
+ _interpolate_component(
671
+ index=index,
672
+ fraction=fraction,
673
+ times=times,
674
+ values=values,
675
+ curve=right.curve,
676
+ slopes=slopes,
677
+ )
678
+ )
679
+ return result[0], result[1]
680
+
681
+
682
+ def _mapped_control_points(
683
+ control_points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
684
+ retime_map: RetimeMap,
685
+ ) -> tuple[MappedControlPoint, ...]:
686
+ mapped: list[MappedControlPoint] = []
687
+ for source_index, point in enumerate(control_points):
688
+ for occurrence in retime_map.source_to_timeline_occurrences(point.time):
689
+ mapped.append(
690
+ MappedControlPoint(
691
+ source_index=source_index,
692
+ source_time=point.time,
693
+ value=point.value,
694
+ interpolation=point.interpolation,
695
+ curve=point.curve,
696
+ aux_value=point.aux_value,
697
+ timeline_start=occurrence.timeline_start,
698
+ timeline_end=occurrence.timeline_end,
699
+ includes_start=occurrence.includes_start,
700
+ includes_end=occurrence.includes_end,
701
+ segment_indices=occurrence.segment_indices,
702
+ )
703
+ )
704
+ return tuple(
705
+ sorted(
706
+ mapped,
707
+ key=lambda item: (
708
+ item.timeline_start,
709
+ item.timeline_end,
710
+ item.source_index,
711
+ ),
712
+ )
713
+ )
714
+
715
+
716
+ def _validate_timeline_wrapper(
717
+ source_track: object, retime_map: object, expected_type: type[object]
718
+ ) -> None:
719
+ if not isinstance(source_track, expected_type):
720
+ raise AnimationValidationError(f"source_track must be {expected_type.__name__}")
721
+ if not isinstance(retime_map, RetimeMap):
722
+ raise AnimationValidationError("retime_map must be RetimeMap")
723
+
724
+
725
+ def _materializable_segment(retime_map: RetimeMap) -> RetimeSegment:
726
+ if len(retime_map.segments) != 1:
727
+ raise UnsupportedAnimationMappingError(
728
+ "materializing timeline control points requires one continuous retime segment"
729
+ )
730
+ segment = retime_map.segments[0]
731
+ if segment.kind == "freeze":
732
+ raise UnsupportedAnimationMappingError(
733
+ "a freeze maps source keyframes to intervals, not unique timeline points"
734
+ )
735
+ if segment.kind == "reverse":
736
+ raise UnsupportedAnimationMappingError(
737
+ "reverse easing cannot be flattened without changing source-track semantics"
738
+ )
739
+ return segment
740
+
741
+
742
+ @dataclass(frozen=True)
743
+ class TimelineAnimatedScalar:
744
+ """Losslessly evaluate a scalar source track through an exact retime map."""
745
+
746
+ source_track: AnimatedScalar
747
+ retime_map: RetimeMap
748
+
749
+ def __post_init__(self) -> None:
750
+ _validate_timeline_wrapper(self.source_track, self.retime_map, AnimatedScalar)
751
+
752
+ @property
753
+ def control_point_occurrences(self) -> tuple[MappedControlPoint, ...]:
754
+ return _mapped_control_points(self.source_track.control_points, self.retime_map)
755
+
756
+ @property
757
+ def notices(self) -> tuple[AnimationNotice, ...]:
758
+ return self.source_track.notices
759
+
760
+ def value_at(self, timeline_time: Fraction) -> float:
761
+ source_time = self.retime_map.timeline_to_source(timeline_time)
762
+ return self.source_track.value_at(source_time)
763
+
764
+ def materialize_timeline_track(self) -> AnimatedScalar:
765
+ """Flatten the only unambiguous subset into ordinary timeline points.
766
+
767
+ Main callers:
768
+ - FFmpeg expression builders that cannot evaluate ``RetimeMap`` first.
769
+
770
+ Why this exists:
771
+ - Forward one-segment retimes preserve point ordering and easing. More
772
+ complex maps remain available through ``value_at`` but must not be
773
+ rewritten into a misleading flat keyframe sequence.
774
+ """
775
+
776
+ segment = _materializable_segment(self.retime_map)
777
+ points: list[ScalarControlPoint] = []
778
+ for point in self.source_track.control_points:
779
+ occurrences = self.retime_map.source_to_timeline_occurrences(point.time)
780
+ if not occurrences:
781
+ raise UnsupportedAnimationMappingError(
782
+ "materializing a partial source-track range requires synthesized boundary points"
783
+ )
784
+ if len(occurrences) != 1 or occurrences[0].is_interval:
785
+ raise UnsupportedAnimationMappingError(
786
+ "source control point does not map to one timeline coordinate"
787
+ )
788
+ points.append(
789
+ ScalarControlPoint(
790
+ time=occurrences[0].timeline_time,
791
+ value=point.value,
792
+ interpolation=point.interpolation,
793
+ curve=point.curve,
794
+ aux_value=point.aux_value,
795
+ )
796
+ )
797
+ if not points:
798
+ raise UnsupportedAnimationMappingError(
799
+ "retime segment does not contain any source control points"
800
+ )
801
+ # Retain the local name to make the forward-only precondition obvious.
802
+ assert segment.kind == "forward"
803
+ return AnimatedScalar(tuple(points))
804
+
805
+
806
+ @dataclass(frozen=True)
807
+ class TimelineAnimatedVec2:
808
+ """Losslessly evaluate a two-component source track through a retime map."""
809
+
810
+ source_track: AnimatedVec2
811
+ retime_map: RetimeMap
812
+
813
+ def __post_init__(self) -> None:
814
+ _validate_timeline_wrapper(self.source_track, self.retime_map, AnimatedVec2)
815
+
816
+ @property
817
+ def control_point_occurrences(self) -> tuple[MappedControlPoint, ...]:
818
+ return _mapped_control_points(self.source_track.control_points, self.retime_map)
819
+
820
+ @property
821
+ def notices(self) -> tuple[AnimationNotice, ...]:
822
+ return self.source_track.notices
823
+
824
+ def value_at(self, timeline_time: Fraction) -> Vec2:
825
+ source_time = self.retime_map.timeline_to_source(timeline_time)
826
+ return self.source_track.value_at(source_time)
827
+
828
+ def materialize_timeline_track(self) -> AnimatedVec2:
829
+ _materializable_segment(self.retime_map)
830
+ points: list[Vec2ControlPoint] = []
831
+ for point in self.source_track.control_points:
832
+ occurrences = self.retime_map.source_to_timeline_occurrences(point.time)
833
+ if not occurrences:
834
+ raise UnsupportedAnimationMappingError(
835
+ "materializing a partial source-track range requires synthesized boundary points"
836
+ )
837
+ if len(occurrences) != 1 or occurrences[0].is_interval:
838
+ raise UnsupportedAnimationMappingError(
839
+ "source control point does not map to one timeline coordinate"
840
+ )
841
+ points.append(
842
+ Vec2ControlPoint(
843
+ time=occurrences[0].timeline_time,
844
+ value=point.value,
845
+ interpolation=point.interpolation,
846
+ curve=point.curve,
847
+ aux_value=point.aux_value,
848
+ )
849
+ )
850
+ if not points:
851
+ raise UnsupportedAnimationMappingError(
852
+ "retime segment does not contain any source control points"
853
+ )
854
+ return AnimatedVec2(tuple(points))
855
+
856
+
857
+ def map_scalar_animation(
858
+ source_track: AnimatedScalar, retime_map: RetimeMap
859
+ ) -> TimelineAnimatedScalar:
860
+ """Create the lossless source-to-timeline scalar evaluation wrapper."""
861
+
862
+ return TimelineAnimatedScalar(source_track=source_track, retime_map=retime_map)
863
+
864
+
865
+ def map_vec2_animation(
866
+ source_track: AnimatedVec2, retime_map: RetimeMap
867
+ ) -> TimelineAnimatedVec2:
868
+ """Create the lossless source-to-timeline vector evaluation wrapper."""
869
+
870
+ return TimelineAnimatedVec2(source_track=source_track, retime_map=retime_map)
871
+
872
+
873
+ __all__ = [
874
+ "AnimatedScalar",
875
+ "AnimatedVec2",
876
+ "AnimationError",
877
+ "AnimationNotice",
878
+ "AnimationValidationError",
879
+ "KEN_BURNS_PROGRESS_CALIBRATION_ID",
880
+ "KEN_BURNS_PROGRESS_MAX_RESIDUAL",
881
+ "KEN_BURNS_PROGRESS_POINTS",
882
+ "MappedControlPoint",
883
+ "ScalarControlPoint",
884
+ "TimelineAnimatedScalar",
885
+ "TimelineAnimatedVec2",
886
+ "UnsupportedAnimationMappingError",
887
+ "Vec2",
888
+ "Vec2ControlPoint",
889
+ "ken_burns_progress",
890
+ "ken_burns_progress_expression",
891
+ "map_scalar_animation",
892
+ "map_vec2_animation",
893
+ ]