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,104 @@
1
+ """PyAV live-audio producer for the standalone Bladeworks server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from fractions import Fraction
7
+ from typing import Callable, Iterator
8
+
9
+ import av
10
+ import numpy as np
11
+
12
+ from ..core.errors import RenderCapabilityError
13
+ from ..core.model import RenderDocument
14
+ from ..core.report import CompatibilityReport
15
+ from .contracts import ComposedAudioFrame
16
+ from ..tensor import audio_pyav
17
+ from ..tensor.audio_delivery import audio_delivery_layout, resolve_audio_delivery
18
+
19
+
20
+ class FFmpegPreviewAudioProducer:
21
+ """Expose the tensor audio graph as seekable planar PCM preview chunks."""
22
+
23
+ def __init__(
24
+ self,
25
+ document: RenderDocument,
26
+ *,
27
+ report: CompatibilityReport,
28
+ ffprobe_path: str,
29
+ chunk_milliseconds: int = 20,
30
+ **_: object,
31
+ ) -> None:
32
+ self.document = document
33
+ self.report = report
34
+ self.ffprobe_path = ffprobe_path
35
+ self.chunk_milliseconds = chunk_milliseconds
36
+ self._closed = False
37
+
38
+ def _samples(self) -> tuple[np.ndarray, int, str]:
39
+ resolution = resolve_audio_delivery(
40
+ self.document,
41
+ ffprobe=self.ffprobe_path,
42
+ report=self.report,
43
+ )
44
+ rate = resolution.output_sample_rate
45
+ layout = audio_delivery_layout(self.document)
46
+ if resolution.mode == "render" and resolution.execution is not None:
47
+ frames = audio_pyav.render_execution_frames(resolution.execution)
48
+ else:
49
+ frames = audio_pyav.render_silence_frames(
50
+ sample_rate=rate,
51
+ ffmpeg_layout=layout,
52
+ duration=resolution.output_duration,
53
+ )
54
+ resampler = av.AudioResampler(format="s16p", layout=layout, rate=rate)
55
+ chunks: list[np.ndarray] = []
56
+ for frame in frames:
57
+ for converted in resampler.resample(frame):
58
+ chunks.append(np.ascontiguousarray(converted.to_ndarray()))
59
+ for converted in resampler.resample(None):
60
+ chunks.append(np.ascontiguousarray(converted.to_ndarray()))
61
+ channels = 1 if layout == "mono" else 2
62
+ samples = np.concatenate(chunks, axis=1) if chunks else np.empty((channels, 0), dtype=np.int16)
63
+ return samples, rate, layout
64
+
65
+ def frames(self, start_time: Fraction, *, is_cancelled) -> Iterator[ComposedAudioFrame]:
66
+ samples, rate, layout = self._samples()
67
+ start = max(0, int(start_time * rate))
68
+ chunk = max(1, rate * self.chunk_milliseconds // 1000)
69
+ for offset in range(start, samples.shape[1], chunk):
70
+ if self._closed or is_cancelled():
71
+ return
72
+ yield ComposedAudioFrame(
73
+ time=Fraction(offset, rate),
74
+ sample_rate=rate,
75
+ layout=layout,
76
+ samples=np.ascontiguousarray(samples[:, offset : offset + chunk]),
77
+ )
78
+
79
+ def close(self) -> None:
80
+ self._closed = True
81
+
82
+
83
+ class FFmpegPreviewAudioFactory:
84
+ """Compatibility-named factory backed entirely by tensor/PyAV audio."""
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ report_for: Callable[[RenderDocument], CompatibilityReport],
90
+ ffprobe_path: str | None = None,
91
+ **_: object,
92
+ ) -> None:
93
+ resolved = ffprobe_path or shutil.which("ffprobe")
94
+ if resolved is None:
95
+ raise RenderCapabilityError("ffprobe is required for Bladeworks preview audio")
96
+ self.report_for = report_for
97
+ self.ffprobe_path = resolved
98
+
99
+ def create(self, document: RenderDocument) -> FFmpegPreviewAudioProducer:
100
+ return FFmpegPreviewAudioProducer(
101
+ document,
102
+ report=self.report_for(document),
103
+ ffprobe_path=self.ffprobe_path,
104
+ )
@@ -0,0 +1,457 @@
1
+ """Public, renderer-owned capability catalog for Bladeworks Studio.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ tensor effect and transition registries
7
+ + FCPXML capability registry (UIDs and typed parameter contracts)
8
+ + core renderer policy (mechanics, media, audio, export)
9
+ -> ``bladeworks_capabilities`` validates registry coverage
10
+ -> authenticated ``GET /api/editor/capabilities``
11
+ -> Studio builds only controls Bladeworks can execute
12
+
13
+ The renderer is the rate limiter. This module does not describe what the
14
+ browser happens to implement today. A missing browser control for an item in
15
+ this response is a Studio bug. Conversely, unsupported entries are explicit
16
+ so the browser cannot accidentally expose controls that Bladeworks rejects.
17
+
18
+ Resource identity is an invariant: every authorable effect and transition has
19
+ the exact FCPXML resource UID from ``FCPXML_RENDER_CAPABILITIES.yaml``. The
20
+ catalog fails loudly when the renderer registry grows without a matching
21
+ resource declaration, preventing a silent UI/backend drift.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from typing import Any, Mapping
28
+
29
+ from fastapi import APIRouter
30
+
31
+ from ..core.capabilities import Capability, CapabilityRegistry
32
+ from ..core.compositor import _MODE_SPECS # renderer-owned canonical map
33
+ from ..core.masks import MASK_AUTHORING_SOURCES, MASK_BLEND_MODES, MAX_MASKS
34
+ from ..tensor.effects import EFFECT_PORTS
35
+ from ..tensor.tr_equirect import ADMITTED_EQUIRECT_IDS
36
+ from ..tensor.tr_phase5 import PHASE5_IDS
37
+ from ..tensor.resolution import DEFAULT_PROFILE, SUPPORTED_PROFILES, RenderMode
38
+ from ..tensor.transitions import ADMITTED_XFADE_IDS, HANDLERS
39
+ from .render_jobs import STUDIO_EXPORT_PROFILES
40
+
41
+ SCHEMA_VERSION = 1
42
+
43
+ _IDENTITY_EFFECTS = frozenset(
44
+ {"cohort_color_curves", "cohort_hue_saturation_curves"}
45
+ )
46
+ _DEFAULT_ONLY_EFFECTS = frozenset(
47
+ {
48
+ "negative",
49
+ "threshold",
50
+ "mirror",
51
+ "colorize",
52
+ "tint",
53
+ "flipped",
54
+ "add_noise",
55
+ "pixellate_default",
56
+ "cohort_drop_shadow",
57
+ "cohort_callout",
58
+ "cohort_directional_blur",
59
+ "cohort_fisheye",
60
+ "cohort_vignette_mask",
61
+ "cohort_kaleidoscope",
62
+ "cohort_perspective_tile",
63
+ }
64
+ )
65
+ _PARTIAL_EFFECTS = frozenset(
66
+ {"cohort_cartoon", "cohort_camcorder", "cohort_focus_blur", "cohort_earthquake"}
67
+ )
68
+ _EXACT_EFFECTS = frozenset({"color_adjustments"})
69
+ _NATIVE_TRANSITION_HANDLERS = frozenset(
70
+ {"cross_dissolve", "fade_color", "wipe", "slide_push"}
71
+ )
72
+
73
+
74
+ def _parameter(
75
+ key: str,
76
+ raw: Mapping[str, Any],
77
+ *,
78
+ animatable: bool,
79
+ ) -> dict[str, Any]:
80
+ """Translate one renderer parameter declaration to the stable HTTP shape.
81
+
82
+ Main callers:
83
+ - ``_effect`` and ``_transition`` while materializing the catalog.
84
+
85
+ Why this exists:
86
+ The internal registry contains calibration constants alongside its public
87
+ type contract. Studio needs the exact serialized key and editing bounds,
88
+ but must not depend on private FFmpeg scale factors.
89
+ """
90
+
91
+ semantic_type = raw.get("semantic_type")
92
+ raw_type = raw.get("type")
93
+ components = int(raw.get("components", 1))
94
+ if raw_type in {"bool", "boolean"} or semantic_type == "boolean":
95
+ parameter_type = "boolean"
96
+ elif semantic_type == "enum" or "allowed" in raw:
97
+ parameter_type = "enum"
98
+ elif raw_type in {"integer", "color", "position", "vector"}:
99
+ parameter_type = {
100
+ "position": "point",
101
+ "vector": "point",
102
+ }.get(str(raw_type), str(raw_type))
103
+ elif components in {3, 4} and "color" in str(raw.get("name", "")).lower():
104
+ parameter_type = "color"
105
+ else:
106
+ parameter_type = "number"
107
+
108
+ result: dict[str, Any] = {
109
+ "key": key,
110
+ "name": str(raw.get("name", key)),
111
+ "type": parameter_type,
112
+ "animatable": animatable,
113
+ }
114
+ if raw.get("required") is True:
115
+ result["required"] = True
116
+ if "default" in raw:
117
+ if parameter_type == "boolean":
118
+ value = raw["default"]
119
+ result["default"] = value if isinstance(value, bool) else str(value).strip().casefold() in {"1", "true"}
120
+ else:
121
+ result["default"] = (
122
+ str(raw["default"]) if parameter_type == "enum" else raw["default"]
123
+ )
124
+ if "minimum" in raw:
125
+ result["min"] = raw["minimum"]
126
+ if "maximum" in raw:
127
+ result["max"] = raw["maximum"]
128
+ if "allowed" in raw and parameter_type != "boolean":
129
+ result["choices"] = [str(value) for value in raw["allowed"]]
130
+ if components != 1:
131
+ if parameter_type == "color":
132
+ component_names = ["red", "green", "blue", "alpha"][:components]
133
+ elif parameter_type == "point":
134
+ component_names = ["x", "y"][:components]
135
+ else:
136
+ component_names = [f"component{index + 1}" for index in range(components)]
137
+ result["components"] = component_names
138
+ return result
139
+
140
+
141
+ def _display_name(entry: Capability) -> str:
142
+ return entry.aliases[0] if entry.aliases else entry.id
143
+
144
+
145
+ def _effect(entry: Capability) -> dict[str, Any]:
146
+ handler = entry.handler or ""
147
+ if handler in _DEFAULT_ONLY_EFFECTS:
148
+ support = "default_only"
149
+ notes = "Bladeworks renders the calibrated default and rejects authored parameters."
150
+ elif handler in _PARTIAL_EFFECTS:
151
+ support = "partial"
152
+ notes = "Bladeworks honors the listed controls; other template controls are unavailable."
153
+ elif handler in _EXACT_EFFECTS:
154
+ support = "exact"
155
+ notes = "Bladeworks honors the complete listed parameter contract."
156
+ else:
157
+ support = "approximate"
158
+ notes = entry.approximation or "Bladeworks renders a calibrated approximation."
159
+ assert entry.uid is not None, f"authorable effect {entry.id} has no FCPXML UID"
160
+ return {
161
+ "id": entry.id,
162
+ "name": _display_name(entry),
163
+ "handler": handler,
164
+ "resource": {"uid": entry.uid, "xfadeId": None},
165
+ "authorable": True,
166
+ "support": support,
167
+ # Ordinary effect parameter animation is rejected or not executed by
168
+ # Tensor. Mask mattes publish their separate, genuinely animated ABI.
169
+ "parameters": [
170
+ _parameter(key, raw, animatable=False)
171
+ for key, raw in entry.parameters.items()
172
+ ],
173
+ "notes": [notes],
174
+ }
175
+
176
+
177
+ def _transition(entry: Capability) -> dict[str, Any]:
178
+ xfade_id = str(entry.xfade["id"]) if entry.xfade else None
179
+ assert entry.uid is not None, f"authorable transition {entry.id} has no FCPXML UID"
180
+ support = "exact" if entry.handler == "cross_dissolve" else "approximate"
181
+ parameters = entry.parameters
182
+ if entry.handler == "cross_dissolve":
183
+ # Tensor implements Final Cut's fixed default dissolve. The template's
184
+ # Look/Ease metadata never changes the kernel and must not become fake
185
+ # Studio controls.
186
+ support = "default_only"
187
+ parameters = {}
188
+ if not entry.parameters:
189
+ support = "default_only" if entry.handler == "xfade" else support
190
+ return {
191
+ "id": entry.id,
192
+ "name": _display_name(entry),
193
+ "handler": entry.handler,
194
+ "resource": {"uid": entry.uid, "xfadeId": xfade_id},
195
+ "authorable": True,
196
+ "support": support,
197
+ "parameters": [
198
+ _parameter(key, raw, animatable=False)
199
+ for key, raw in parameters.items()
200
+ ],
201
+ "notes": [
202
+ entry.approximation
203
+ or (
204
+ "Exact premultiplied linear-light dissolve."
205
+ if support == "exact"
206
+ else "Calibrated Bladeworks transition."
207
+ )
208
+ ],
209
+ }
210
+
211
+
212
+ def _slide_push_transitions(entry: Capability) -> list[dict[str, Any]]:
213
+ """Publish the two real modes behind Final Cut's shared resource UID.
214
+
215
+ Main callers:
216
+ - ``_registry_surfaces`` while expanding the native transition registry.
217
+
218
+ Final Cut uses one resource for Slide and Push and distinguishes them with
219
+ parameter key ``5``. Separate catalog tiles remain executable because each
220
+ carries a one-choice fixed mode parameter that Studio serializes normally.
221
+ """
222
+
223
+ direction = entry.parameters["4"]
224
+ result: list[dict[str, Any]] = []
225
+ for identifier, name, mode in (
226
+ ("transition-slide", "Slide", "0"),
227
+ ("transition-push", "Push", "2"),
228
+ ):
229
+ public = _transition(entry)
230
+ public["id"] = identifier
231
+ public["name"] = name
232
+ public["parameters"] = [
233
+ _parameter("4", direction, animatable=False),
234
+ _parameter(
235
+ "5",
236
+ {"name": "Mode", "type": "scalar", "default": mode, "allowed": [mode], "semantic_type": "enum"},
237
+ animatable=False,
238
+ ),
239
+ ]
240
+ public["notes"] = [
241
+ f"Calibrated Final Cut {name} using shared resource mode {mode}."
242
+ ]
243
+ result.append(public)
244
+ return result
245
+
246
+
247
+ def _registry_surfaces() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
248
+ """Resolve public effects/transitions and reject any registry drift.
249
+
250
+ Main callers:
251
+ - ``bladeworks_capabilities`` on each HTTP request and in drift tests.
252
+
253
+ Keeping this check on the serving path matters: adding a tensor port without
254
+ a browser-safe FCPXML identity is a broken release, not an optional catalog
255
+ omission.
256
+ """
257
+
258
+ registry = CapabilityRegistry.load()
259
+ effect_entries = [
260
+ entry
261
+ for entry in registry.entries
262
+ if entry.kind == "video_filter" and entry.handler in EFFECT_PORTS
263
+ ]
264
+ registered_effects = set(EFFECT_PORTS)
265
+ catalog_effects = {entry.handler for entry in effect_entries if entry.handler}
266
+ if catalog_effects != registered_effects:
267
+ raise RuntimeError(
268
+ "Tensor effect registry and FCPXML resource registry disagree: "
269
+ f"ports_only={sorted(registered_effects - catalog_effects)!r}, "
270
+ f"resources_only={sorted(catalog_effects - registered_effects)!r}"
271
+ )
272
+
273
+ admitted_xfade_ids = set(ADMITTED_XFADE_IDS) | set(PHASE5_IDS) | set(ADMITTED_EQUIRECT_IDS)
274
+ transition_entries = [
275
+ entry
276
+ for entry in registry.entries
277
+ if entry.kind == "transition"
278
+ and (
279
+ entry.handler in _NATIVE_TRANSITION_HANDLERS
280
+ or (entry.xfade is not None and entry.xfade.get("id") in admitted_xfade_ids)
281
+ )
282
+ ]
283
+ catalog_xfade_ids = {
284
+ str(entry.xfade["id"]) for entry in transition_entries if entry.xfade is not None
285
+ }
286
+ if catalog_xfade_ids != admitted_xfade_ids:
287
+ raise RuntimeError(
288
+ "Tensor transition admission and FCPXML resource registry disagree: "
289
+ f"ports_only={sorted(admitted_xfade_ids - catalog_xfade_ids)!r}, "
290
+ f"resources_only={sorted(catalog_xfade_ids - admitted_xfade_ids)!r}"
291
+ )
292
+ native_handlers = {entry.handler for entry in transition_entries} & _NATIVE_TRANSITION_HANDLERS
293
+ if native_handlers != _NATIVE_TRANSITION_HANDLERS or not _NATIVE_TRANSITION_HANDLERS <= set(HANDLERS):
294
+ raise RuntimeError("Native tensor transition handlers and capability resources disagree")
295
+
296
+ effects = [
297
+ _effect(entry)
298
+ for entry in effect_entries
299
+ if entry.handler not in _IDENTITY_EFFECTS
300
+ ]
301
+ transitions = [
302
+ public
303
+ for entry in transition_entries
304
+ for public in (
305
+ _slide_push_transitions(entry)
306
+ if entry.handler == "slide_push"
307
+ else [_transition(entry)]
308
+ )
309
+ ]
310
+ return effects, transitions
311
+
312
+
313
+ def bladeworks_capabilities() -> dict[str, Any]:
314
+ """Return the complete public authoring and delivery contract."""
315
+
316
+ effects, transitions = _registry_surfaces()
317
+ blend_modes_by_name = {spec.canonical_name: spec for spec in _MODE_SPECS.values()}
318
+ blend_modes = [
319
+ {
320
+ "id": "".join(character.lower() for character in name if character.isalnum()),
321
+ "name": name,
322
+ "fcpxmlValue": re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-"),
323
+ "support": "exact"
324
+ if spec.semantic_status == "exact_alpha"
325
+ else "approximate",
326
+ "authorable": True,
327
+ }
328
+ for name, spec in sorted(blend_modes_by_name.items())
329
+ ]
330
+ return {
331
+ "schemaVersion": SCHEMA_VERSION,
332
+ "renderer": "tensor",
333
+ "mechanics": [
334
+ {"id": "timeline", "name": "Spine, connected clips, lanes and gaps", "support": "exact", "authorable": True},
335
+ {"id": "transform", "name": "Position, scale, rotation and anchor", "support": "exact", "authorable": True, "animatable": True},
336
+ {"id": "crop", "name": "Trim, Crop and Ken Burns", "support": "approximate", "authorable": True, "animatable": True, "modes": ["trim", "crop", "ken_burns"]},
337
+ {"id": "distort", "name": "Four-corner Distort", "support": "exact", "authorable": True, "animatable": True},
338
+ {"id": "spatialConform", "name": "Spatial conform", "support": "exact", "authorable": True, "modes": ["fit", "fill", "none"]},
339
+ {"id": "opacity", "name": "Opacity", "support": "exact", "authorable": True, "animatable": True},
340
+ {
341
+ "id": "masks",
342
+ "name": "Numeric shape, draw, color and luma masks",
343
+ "support": "approximate",
344
+ "authorable": True,
345
+ "animatable": True,
346
+ "maximumMasks": MAX_MASKS,
347
+ "blendModes": list(MASK_BLEND_MODES),
348
+ "invert": True,
349
+ "sourceKinds": [
350
+ {
351
+ **{
352
+ key: list(value) if isinstance(value, tuple) else value
353
+ for key, value in source.items()
354
+ if key != "parameters"
355
+ },
356
+ "parameters": [
357
+ {
358
+ "key": parameter["key"],
359
+ "name": parameter["name"],
360
+ "type": parameter["type"],
361
+ "min": parameter.get("minimum"),
362
+ "max": parameter.get("maximum"),
363
+ "default": parameter.get("default"),
364
+ "components": list(parameter.get("components", ())),
365
+ "units": parameter["units"],
366
+ "minimumItems": parameter.get("minimumItems"),
367
+ "maximumItems": parameter.get("maximumItems"),
368
+ "convex": parameter.get("convex"),
369
+ "animatable": parameter["animatable"],
370
+ }
371
+ for parameter in source["parameters"]
372
+ ],
373
+ }
374
+ for source in MASK_AUTHORING_SOURCES
375
+ ],
376
+ },
377
+ {"id": "titles", "name": "Titles and captions", "support": "partial", "authorable": True, "notes": "Supported title text/styles rasterize in the preview service; opaque Motion rigs reject."},
378
+ {"id": "customSolid", "name": "Custom Solid generator", "support": "exact", "authorable": True},
379
+ {"id": "nestedScopes", "name": "Compound, multicam, sync and audition scopes", "support": "exact", "authorable": True},
380
+ {"id": "preview", "name": "Seek and scan preview", "support": "exact", "authorable": False, "qualities": [720, 540, 480]},
381
+ ],
382
+ "blendModes": blend_modes,
383
+ "retime": {
384
+ "support": "exact",
385
+ "authorable": True,
386
+ "modes": ["constant", "reverse", "freeze", "piecewise_linear"],
387
+ "frameSampling": ["floor"],
388
+ "preservePitch": True,
389
+ "notes": "Matches Final Cut's constant speed, Reverse, Hold and segmented retime editor model. Smooth speed transitions are unsupported.",
390
+ },
391
+ "effects": effects,
392
+ "transitions": transitions,
393
+ "audio": {
394
+ "support": "exact",
395
+ "authorable": True,
396
+ "controls": ["gain", "mute", "fade_in", "fade_out", "pan", "roles", "j_l_edits", "resampling"],
397
+ "outputLayouts": ["mono", "stereo"],
398
+ },
399
+ "media": {
400
+ "support": "exact",
401
+ "decodedPixelFormats": ["yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p", "yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv444p12le"],
402
+ "colorMatrices": ["bt709", "bt601", "smpte170m", "bt2020nc"],
403
+ "hdrInputTransfers": ["arib-std-b67", "smpte2084"],
404
+ "missingMedia": "placeholder_video_and_silent_audio",
405
+ },
406
+ "export": {
407
+ "support": "exact",
408
+ "supportedResolutions": [
409
+ int(profile.value.removesuffix("p"))
410
+ for profile in SUPPORTED_PROFILES[RenderMode.RENDER]
411
+ ],
412
+ "defaultResolution": int(
413
+ DEFAULT_PROFILE[RenderMode.RENDER].value.removesuffix("p")
414
+ ),
415
+ "profiles": [
416
+ {
417
+ "id": profile.id,
418
+ "container": profile.container,
419
+ "video": profile.video_codec,
420
+ "audio": profile.audio_codec,
421
+ "alpha": profile.alpha,
422
+ "defaultResolution": 1080,
423
+ }
424
+ for profile in STUDIO_EXPORT_PROFILES.values()
425
+ ],
426
+ },
427
+ "unsupported": [
428
+ {"id": "stabilization", "category": "mechanic", "reason": "Bladeworks rejects stabilization metadata."},
429
+ {"id": "rollingShutter", "category": "mechanic", "reason": "Bladeworks rejects rolling-shutter correction."},
430
+ {"id": "tracking", "category": "mask", "reason": "Opaque, tracked, Magnetic, Auto and ML masks reject."},
431
+ {"id": "smoothRetime", "category": "retime", "reason": "Smooth/eased time-map interpolation rejects."},
432
+ {"id": "frameBlending", "category": "retime", "reason": "Frame Blending is not implemented."},
433
+ {"id": "opticalFlow", "category": "retime", "reason": "Optical Flow and ML retiming are not implemented."},
434
+ {"id": "colorCurves", "category": "effect", "reason": "Authored curve data rejects; the identity port is not authorable."},
435
+ {"id": "hueSaturationCurves", "category": "effect", "reason": "Authored curve data rejects; the identity port is not authorable."},
436
+ {"id": "customLuts", "category": "effect", "reason": "Custom LUTs reject."},
437
+ {"id": "motionTitleRigs", "category": "title", "reason": "Opaque Motion title rigs reject."},
438
+ {"id": "nonCustomSolidGenerators", "category": "generator", "reason": "Only Custom Solid generators render."},
439
+ {"id": "surroundAudio", "category": "audio", "reason": "5.1 output rejects before delivery planning."},
440
+ {"id": "alphaSourceMedia", "category": "media", "reason": "Alpha-carrying decoded source formats reject."},
441
+ {"id": "hdrDelivery", "category": "export", "reason": "HDR input is tone-mapped to SDR; HDR output is not offered."},
442
+ {"id": "oracleMezzanine", "category": "export", "reason": "The tensor encoder has no ProRes 4:2:2 10-bit exit; the legacy CPU-only oracle profile is not a Studio capability."},
443
+ {"id": "crossChannelBlendModes", "category": "blend", "reason": "Hue, Saturation, Color and Luminosity blend modes reject."},
444
+ ],
445
+ }
446
+
447
+
448
+ def create_capability_router() -> APIRouter:
449
+ """Build the narrow router mounted behind the server bearer dependency."""
450
+
451
+ router = APIRouter(prefix="/api/editor", tags=["capabilities"])
452
+
453
+ @router.get("/capabilities")
454
+ async def capabilities() -> dict[str, Any]:
455
+ return bladeworks_capabilities()
456
+
457
+ return router