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,299 @@
1
+ """Calibrated parameter mappings for the four non-flat carryover transitions.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ genuine Final Cut parameter key/value
7
+ -> capability-registry type/range validation
8
+ -> this module's renderer-owned semantic mapping
9
+ -> radial-zoom strengths/centers or a fixed Black Hole expression
10
+
11
+ This module deliberately knows nothing about XML parsing or FFmpeg graph
12
+ assembly. It converts already validated values into a small immutable plan.
13
+ ``transitions.stock`` attaches that plan to the generic stock-transition plan,
14
+ and ``ffmpeg`` performs the final project-size center conversion.
15
+
16
+ Product invariants
17
+ ------------------
18
+
19
+ * Final Cut text never becomes an FFmpeg expression fragment.
20
+ * Zoom and Cross Zoom preserve their previously calibrated defaults exactly.
21
+ * A Motion point is converted to a crop center only after project dimensions
22
+ are known, and an off-canvas result is rejected rather than clamped.
23
+ * Black Hole's hidden structural fields are accepted only at their proved
24
+ values; its pixel-inert visible nondefaults are rejected explicitly.
25
+
26
+ Why this exists
27
+ ---------------
28
+
29
+ The carryovers do not share the flat-transition geometry and are not 360°
30
+ panorama operations. Keeping their calibration here prevents ``stock.py``
31
+ from turning into another parameter-specific conditional ladder.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ from dataclasses import dataclass
38
+ from typing import Any, Literal, Mapping
39
+
40
+ from ..core.errors import FCPXMLCompileError
41
+
42
+
43
+ # Genuine Final Cut 12.3 roundtrip keys. Keeping these beside the mappings
44
+ # makes it obvious which serialized contract each formula consumes.
45
+ ZOOM_AMOUNT_KEY = "9999/988471386/3/999166426/1"
46
+ ZOOM_CENTER_KEY = "9999/988471386/3/999166426/2"
47
+
48
+ CROSS_ZOOM_AMOUNT_KEY = "3"
49
+ CROSS_ZOOM_START_POINT_KEY = "1"
50
+ CROSS_ZOOM_END_POINT_KEY = "2"
51
+
52
+ BLACK_HOLE_DURATION_KEY = "9999/10059/10065/3/989648928/1"
53
+ BLACK_HOLE_TRAIL_OPACITY_KEY = "9999/10059/10065/3/989648928/10001"
54
+ BLACK_HOLE_FLIP_KEY = "9999/10059/10065/3/989648928/10002"
55
+ BLACK_HOLE_INPUT_POINTS_KEY = "9999/10059/10065/3/989648928/10003"
56
+ BLACK_HOLE_ECHOES_KEY = "9999/10059/10065/3/989648928/2"
57
+ BLACK_HOLE_DECAY_KEY = "9999/10059/10065/3/989648928/3"
58
+ BLACK_HOLE_TRAIL_ON_KEY = "9999/10059/10065/3/989648928/4"
59
+
60
+
61
+ CenterSpace = Literal["normalized", "motion-project-height"]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RadialZoomCenter:
66
+ """One bounded Final Cut center plus its explicit coordinate space."""
67
+
68
+ value: tuple[float, float]
69
+ space: CenterSpace
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class CarryoverRadialPlan:
74
+ """The calibrated radial parameters consumed by the stock FFmpeg graph."""
75
+
76
+ strength: float
77
+ spread: float
78
+ outgoing_center: RadialZoomCenter
79
+ incoming_center: RadialZoomCenter
80
+
81
+
82
+ def build_radial_plan(
83
+ implementation_id: str,
84
+ values: Mapping[str, Any],
85
+ ) -> CarryoverRadialPlan:
86
+ """Map Zoom-family controls while preserving both old default renders.
87
+
88
+ Main callers:
89
+ - ``transitions.stock.build_stock_transition_plan`` for Zoom and Cross
90
+ Zoom after the generic registry contract has validated every value.
91
+
92
+ Final Cut Zoom publishes Amount with a 100 default. Cross Zoom publishes
93
+ a different FxPlug Amount whose default is 50, plus normalized start/end
94
+ points. The two amount normalizations below therefore produce the exact
95
+ historical ``strength`` and ``spread`` values at their respective defaults.
96
+ """
97
+
98
+ if implementation_id == "zoom_blur_default":
99
+ amount = _scalar(values, ZOOM_AMOUNT_KEY, default=100.0)
100
+ center = RadialZoomCenter(
101
+ _vector2(values, ZOOM_CENTER_KEY, default=(0.5, 0.5)),
102
+ "motion-project-height",
103
+ )
104
+ factor = amount / 100.0
105
+ return CarryoverRadialPlan(
106
+ strength=1.70 * factor,
107
+ spread=0.52 * factor,
108
+ outgoing_center=center,
109
+ incoming_center=center,
110
+ )
111
+
112
+ if implementation_id == "cross_zoom_default":
113
+ amount = _scalar(values, CROSS_ZOOM_AMOUNT_KEY, default=50.0)
114
+ factor = amount / 50.0
115
+ return CarryoverRadialPlan(
116
+ strength=2.00 * factor,
117
+ spread=0.62 * factor,
118
+ outgoing_center=RadialZoomCenter(
119
+ _vector2(values, CROSS_ZOOM_START_POINT_KEY, default=(0.75, 0.5)),
120
+ "normalized",
121
+ ),
122
+ incoming_center=RadialZoomCenter(
123
+ _vector2(values, CROSS_ZOOM_END_POINT_KEY, default=(0.25, 0.5)),
124
+ "normalized",
125
+ ),
126
+ )
127
+
128
+ raise FCPXMLCompileError(
129
+ f"carryover radial mapper does not own implementation {implementation_id!r}"
130
+ )
131
+
132
+
133
+ def resolve_radial_center(
134
+ center: RadialZoomCenter,
135
+ *,
136
+ width: int,
137
+ height: int,
138
+ ) -> tuple[float, float]:
139
+ """Convert a proved Final Cut center to a normalized crop anchor.
140
+
141
+ Main callers:
142
+ - The CPU radial-zoom graph builder, after the output dimensions are known.
143
+
144
+ Why this exists:
145
+ Cross Zoom serializes ordinary normalized points. Motion's Zoom template
146
+ instead serializes offsets in a square coordinate system whose unit scale
147
+ is the project height and whose neutral center is ``0.5 0.5``. A genuine
148
+ 540x960 UI roundtrip (75 px, -120 px -> ``133.833 -119.5``) proves this
149
+ conversion. Off-canvas anchors need a different sampling primitive, so
150
+ this stock crop implementation rejects them explicitly.
151
+ """
152
+
153
+ if width <= 0 or height <= 0:
154
+ raise FCPXMLCompileError("radial Zoom needs positive project dimensions")
155
+ x, y = center.value
156
+ if center.space == "normalized":
157
+ resolved = (x, y)
158
+ elif center.space == "motion-project-height":
159
+ resolved = (
160
+ 0.5 + (x - 0.5) / float(height),
161
+ 0.5 + (y - 0.5) / float(height),
162
+ )
163
+ else: # pragma: no cover - the Literal plus frozen constructors own this.
164
+ raise FCPXMLCompileError(f"unknown radial Zoom center space {center.space!r}")
165
+ if any(not math.isfinite(component) for component in resolved):
166
+ raise FCPXMLCompileError("radial Zoom center must be finite")
167
+ if any(component < 0.0 or component > 1.0 for component in resolved):
168
+ raise FCPXMLCompileError(
169
+ "radial Zoom center resolves outside the project canvas; "
170
+ "the stock CPU crop supports normalized centers in [0, 1]"
171
+ )
172
+ return resolved
173
+
174
+
175
+ def _plane_sample(source: str, x: str, y: str) -> str:
176
+ """Return one color-preserving xfade sample from ``a`` or ``b``."""
177
+
178
+ return (
179
+ f"if(eq(PLANE,0),{source}0({x},{y}),"
180
+ f"if(eq(PLANE,1),{source}1({x},{y}),"
181
+ f"if(eq(PLANE,2),{source}2({x},{y}),"
182
+ f"{source}3({x},{y}))))"
183
+ )
184
+
185
+
186
+ # The default expression is byte-for-byte the previously reviewed Black Hole
187
+ # implementation. ``build_black_hole_expression`` returns it unchanged while
188
+ # Trails is disabled, preserving existing default semantics and snapshots.
189
+ _ELAPSED = "(1-P)"
190
+ _REMAINING = f"pow(max(0,1-clip(({_ELAPSED}-0.18)/0.55,0,1)),0.6)"
191
+ _VERTICAL_SCALE = f"(1-clip(({_ELAPSED}-0.48)/0.26,0,1))"
192
+ _HALF_HEIGHT = f"(H/2*{_VERTICAL_SCALE})"
193
+ _Y = f"clip((Y-H/2)/{_VERTICAL_SCALE}+H/2,0,H-1)"
194
+ _VERTICAL_POSITION = f"abs((Y-H/2)/max(1,{_HALF_HEIGHT}))"
195
+ _PROFILE = f"(0.04+0.96*pow({_VERTICAL_POSITION},0.55))"
196
+ _PINCH_PROGRESS = "clip(((1-P)-0.05)/0.46,0,1)"
197
+ _ACTIVE_PROFILE = f"(1-{_PINCH_PROGRESS}*(1-{_PROFILE}))"
198
+ _SCALE = f"({_REMAINING}*{_ACTIVE_PROFILE})"
199
+ _HALF_WIDTH = f"(W/2*{_SCALE})"
200
+ _X = f"clip((X-W/2)/max(0.001,{_SCALE})+W/2,0,W-1)"
201
+ _SAMPLE = _plane_sample("a", _X, _Y)
202
+ _GHOST_SCALE = f"(min(1,{_SCALE}+0.16*{_REMAINING}))"
203
+ _GHOST_WIDTH = f"(W/2*{_GHOST_SCALE})"
204
+ _GHOST_VERTICAL_SCALE = f"min(1,1.08*{_VERTICAL_SCALE})"
205
+ _GHOST_HALF_HEIGHT = f"(H/2*{_GHOST_VERTICAL_SCALE})"
206
+ _GHOST_Y = f"clip((Y-H/2)/max(0.001,{_GHOST_VERTICAL_SCALE})+H/2,0,H-1)"
207
+ _GHOST_X = f"clip((X-W/2)/max(0.001,{_GHOST_SCALE})+W/2,0,W-1)"
208
+ _GHOST_SAMPLE = _plane_sample("a", _GHOST_X, _GHOST_Y)
209
+ _OPACITY_RAW = "clip((P-0.2)/0.4,0,1)"
210
+ _OPACITY = f"({_OPACITY_RAW}*{_OPACITY_RAW}*(3-2*{_OPACITY_RAW}))"
211
+ _ACTIVE_SAMPLE = f"({_OPACITY}*{_SAMPLE}+(1-{_OPACITY})*B)"
212
+ _GHOST_OPACITY = f"(0.16*{_OPACITY})"
213
+ _ACTIVE_GHOST = f"({_GHOST_OPACITY}*{_GHOST_SAMPLE}+(1-{_GHOST_OPACITY})*B)"
214
+ BLACK_HOLE_DEFAULT_EXPRESSION = (
215
+ "if(gte(P,1),A,if(lte(P,0),B,"
216
+ f"if(lte(abs(Y-H/2),{_HALF_HEIGHT})*"
217
+ f"lte(abs(X-W/2),{_HALF_WIDTH}),{_ACTIVE_SAMPLE},"
218
+ f"if(lte(abs(Y-H/2),{_GHOST_HALF_HEIGHT})*"
219
+ f"lte(abs(X-W/2),{_GHOST_WIDTH}),{_ACTIVE_GHOST},B))))"
220
+ )
221
+
222
+
223
+ def build_black_hole_expression(values: Mapping[str, Any]) -> str:
224
+ """Preserve Black Hole's default and reject its inert serialized controls.
225
+
226
+ Main callers:
227
+ - ``transitions.stock.build_stock_transition_plan``.
228
+
229
+ Final Cut serializes the Trails object and preserves every authored value,
230
+ but genuine renders for all fourteen nondefault settings are byte-identical
231
+ to its default movie. Rendering a portable-only response would invent a
232
+ semantic contract. The registry still types all seven published keys so
233
+ malformed or unknown values fail normally; this boundary accepts only the
234
+ exact visible defaults plus the three fixed structural values.
235
+ """
236
+
237
+ duration = _scalar(values, BLACK_HOLE_DURATION_KEY, default=0.0)
238
+ _required_boolean(values, BLACK_HOLE_FLIP_KEY, expected=False)
239
+ _required_boolean(values, BLACK_HOLE_INPUT_POINTS_KEY, expected=True)
240
+ _required_scalar(values, BLACK_HOLE_DECAY_KEY, expected=1.0)
241
+ echoes_raw = _scalar(values, BLACK_HOLE_ECHOES_KEY, default=4.0)
242
+ if not echoes_raw.is_integer():
243
+ raise FCPXMLCompileError("Black Hole Echoes must be an integer")
244
+ echoes = int(echoes_raw)
245
+ opacity = _scalar(values, BLACK_HOLE_TRAIL_OPACITY_KEY, default=1.0)
246
+ trail_on_raw = _scalar(values, BLACK_HOLE_TRAIL_ON_KEY, default=0.0)
247
+ if not trail_on_raw.is_integer() or int(trail_on_raw) not in {0, 1}:
248
+ raise FCPXMLCompileError("Black Hole Trail On must be Light (0) or Dark (1)")
249
+ trail_on = int(trail_on_raw)
250
+ if (duration, opacity, echoes, trail_on) != (0.0, 1.0, 4, 0):
251
+ raise FCPXMLCompileError(
252
+ "Black Hole Trails controls preserve XML but produce no Final Cut "
253
+ "pixel response; nondefault values are explicitly unsupported"
254
+ )
255
+ return BLACK_HOLE_DEFAULT_EXPRESSION
256
+
257
+
258
+ def _scalar(values: Mapping[str, Any], key: str, *, default: float) -> float:
259
+ value = values.get(key, default)
260
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
261
+ raise FCPXMLCompileError(f"validated carryover parameter {key!r} must be scalar")
262
+ number = float(value)
263
+ if not math.isfinite(number):
264
+ raise FCPXMLCompileError(f"validated carryover parameter {key!r} must be finite")
265
+ return number
266
+
267
+
268
+ def _required_scalar(values: Mapping[str, Any], key: str, *, expected: float) -> None:
269
+ value = _scalar(values, key, default=expected)
270
+ if value != expected:
271
+ raise FCPXMLCompileError(
272
+ f"Black Hole structural parameter {key!r} must remain {expected:g}"
273
+ )
274
+
275
+
276
+ def _required_boolean(values: Mapping[str, Any], key: str, *, expected: bool) -> None:
277
+ """Validate a registry-typed hidden boolean at its one structural value."""
278
+
279
+ value = values.get(key, expected)
280
+ if not isinstance(value, bool) or value is not expected:
281
+ raise FCPXMLCompileError(
282
+ f"Black Hole structural parameter {key!r} must remain "
283
+ f"{'true' if expected else 'false'}"
284
+ )
285
+
286
+
287
+ def _vector2(
288
+ values: Mapping[str, Any],
289
+ key: str,
290
+ *,
291
+ default: tuple[float, float],
292
+ ) -> tuple[float, float]:
293
+ value = values.get(key, default)
294
+ if not isinstance(value, tuple) or len(value) != 2:
295
+ raise FCPXMLCompileError(f"validated carryover parameter {key!r} must be vec2")
296
+ resolved = (float(value[0]), float(value[1]))
297
+ if any(not math.isfinite(component) for component in resolved):
298
+ raise FCPXMLCompileError(f"validated carryover parameter {key!r} must be finite")
299
+ return resolved