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,349 @@
1
+ """Foreground Uvicorn lifecycle for one opened ``.fcpxmld`` bundle.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ bladeworks server run PATH
7
+ -> create disposable instance directory
8
+ -> open, hash, and compile Info.fcpxml
9
+ -> bind one loopback socket, including port 0 selection
10
+ -> print one machine-readable ready record
11
+ -> Uvicorn serves until SIGINT/SIGTERM
12
+ -> app lifespan cancels preview/render work
13
+ -> remove disposable instance state
14
+
15
+ The runner owns process lifecycle only. Source and media routers are mounted
16
+ by the final application assembly, keeping their filesystem behavior in their
17
+ dedicated modules.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shutil
24
+ import socket
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import threading
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from urllib.error import HTTPError, URLError
32
+ from urllib.request import Request, urlopen
33
+
34
+ from .application import create_local_preview_app
35
+ from .capabilities import create_capability_router
36
+ from .contracts import PreviewAPIError
37
+ from .media_library import MediaLibrary
38
+ from .media_routes import create_media_router
39
+ from .security import new_auth_token, validate_loopback_origin
40
+ from .source import EDITOR_PROFILE, OpenedSourceStore
41
+ from .source_routes import build_compatibility_router, build_source_router
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ServerConfig:
46
+ source: Path
47
+ host: str = "127.0.0.1"
48
+ port: int = 8765
49
+ device: str = "auto"
50
+ decoder_threads: int = 2
51
+ history_limit: int = 50
52
+ render_directory: Path | None = None
53
+ log_level: str = "info"
54
+ strict: bool = False
55
+ allowed_origins: tuple[str, ...] = ()
56
+
57
+
58
+ def _selected_device(requested: str) -> str:
59
+ if requested != "auto":
60
+ from ..tensor.renderer import require_torch_device
61
+
62
+ require_torch_device(requested)
63
+ return requested
64
+ import torch
65
+
66
+ if torch.backends.mps.is_available():
67
+ return "mps"
68
+ if torch.cuda.is_available():
69
+ return "cuda"
70
+ return "cpu"
71
+
72
+
73
+ def _listener(host: str, port: int) -> socket.socket:
74
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
75
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
76
+ try:
77
+ listener.bind((host, port))
78
+ listener.listen(2048)
79
+ except BaseException:
80
+ listener.close()
81
+ raise
82
+ return listener
83
+
84
+
85
+ def run_server(config: ServerConfig) -> int:
86
+ """Run the API-only server without importing or launching a browser.
87
+
88
+ This is the hard headless entry point used by ``bladeworks server run``.
89
+ Studio hosting and browser launch are reachable only through
90
+ :func:`run_studio`.
91
+ """
92
+
93
+ return _run_server(config, mode="server", open_browser=False)
94
+
95
+
96
+ def run_studio(config: ServerConfig, *, open_browser: bool = True) -> int:
97
+ """Run the API and packaged editor in one foreground Uvicorn process."""
98
+
99
+ return _run_server(config, mode="studio", open_browser=open_browser)
100
+
101
+
102
+ def _run_server(config: ServerConfig, *, mode: str, open_browser: bool) -> int:
103
+ """Run one local process and clean its disposable state.
104
+
105
+ Main callers:
106
+ - ``run_server`` for API-only/headless use.
107
+ - ``run_studio`` for same-origin packaged editor use.
108
+
109
+ Why this exists:
110
+ Uvicorn's convenience function does not expose the selected port when
111
+ binding port zero. Owning the socket lets the launcher receive the exact
112
+ URL without scraping human log output.
113
+ """
114
+
115
+ if mode not in {"server", "studio"}:
116
+ raise ValueError(f"Unknown Bladeworks server mode: {mode}")
117
+ if config.host != "127.0.0.1":
118
+ raise ValueError("Bladeworks server initially binds to 127.0.0.1 only.")
119
+ if not 0 <= config.port <= 65535:
120
+ raise ValueError("Server port must be between 0 and 65535.")
121
+ origins = tuple(validate_loopback_origin(value) for value in config.allowed_origins)
122
+ instance_root = Path(tempfile.gettempdir()) / "bladeworks"
123
+ instance_root.mkdir(parents=True, exist_ok=True)
124
+ instance_directory = Path(tempfile.mkdtemp(prefix="instance-", dir=instance_root))
125
+ instance_id = instance_directory.name.removeprefix("instance-")
126
+ render_directory = (
127
+ config.render_directory.expanduser().resolve()
128
+ if config.render_directory is not None
129
+ else instance_directory / "renders"
130
+ )
131
+ listener: socket.socket | None = None
132
+ service = None
133
+ renders = None
134
+ try:
135
+ store = OpenedSourceStore.open(
136
+ config.source,
137
+ history_directory=instance_directory / "history",
138
+ history_limit=config.history_limit,
139
+ strict=config.strict,
140
+ )
141
+ device = _selected_device(config.device)
142
+ token = new_auth_token()
143
+ app_holder: dict[str, object] = {}
144
+
145
+ def readiness() -> dict[str, object]:
146
+ service = app_holder.get("service")
147
+ renders = app_holder.get("renders")
148
+ try:
149
+ status = store.status()
150
+ source_payload = status.payload()
151
+ ready = status.compile_status == "ready"
152
+ except PreviewAPIError as error:
153
+ source_payload = {
154
+ "diskVersion": None,
155
+ "loadedVersion": None,
156
+ "compileStatus": error.code,
157
+ "degraded": False,
158
+ "editorProfile": EDITOR_PROFILE,
159
+ "error": error.message,
160
+ }
161
+ ready = False
162
+ return {
163
+ "ready": ready,
164
+ "mode": mode,
165
+ "instanceId": instance_id,
166
+ "sourcePath": str(store.bundle_path),
167
+ **source_payload,
168
+ "device": device,
169
+ "activeSessions": getattr(service, "active_count", 0),
170
+ "activeRenders": getattr(renders, "active_count", 0),
171
+ }
172
+
173
+ app = create_local_preview_app(
174
+ documents=store,
175
+ report_for=store.report_for,
176
+ artifact_directory=render_directory,
177
+ device=device,
178
+ decoder_threads=config.decoder_threads,
179
+ encoder_preset="veryfast",
180
+ auth_token=token,
181
+ allowed_origins=origins,
182
+ readiness=readiness,
183
+ protected_routers=(
184
+ create_capability_router(),
185
+ build_source_router(store),
186
+ build_compatibility_router(store),
187
+ create_media_router(MediaLibrary(store.bundle_path)),
188
+ ),
189
+ )
190
+ if mode == "studio":
191
+ from .studio import mount_studio
192
+
193
+ mount_studio(app)
194
+ service = app.state.preview_service
195
+ renders = app.state.render_service
196
+ app_holder["service"] = service
197
+ app_holder["renders"] = renders
198
+
199
+ listener = _listener(config.host, config.port)
200
+ selected_port = listener.getsockname()[1]
201
+ url = f"http://{config.host}:{selected_port}"
202
+ status = store.status()
203
+ ready_payload: dict[str, object] = {
204
+ "event": "ready",
205
+ "instanceId": instance_id,
206
+ "url": url,
207
+ "sourcePath": str(store.bundle_path),
208
+ "version": status.disk_version,
209
+ "editorProfile": EDITOR_PROFILE,
210
+ "authToken": token,
211
+ }
212
+ studio_url: str | None = None
213
+ if mode == "studio":
214
+ studio_url = f"{url}/?runtime=localhost#runtimeToken={token}"
215
+ ready_payload["mode"] = "studio"
216
+ ready_payload["studioUrl"] = studio_url
217
+ print(
218
+ json.dumps(ready_payload, separators=(",", ":")),
219
+ flush=True,
220
+ )
221
+ if studio_url is not None and open_browser:
222
+ _launch_browser(studio_url)
223
+
224
+ import uvicorn
225
+
226
+ shutdown_started = threading.Event()
227
+
228
+ class ForegroundServer(uvicorn.Server):
229
+ """Wake preview streams as soon as a foreground signal arrives.
230
+
231
+ Uvicorn waits for open HTTP responses before running application
232
+ lifespan cleanup. An SSE response is intentionally unbounded, so
233
+ waiting for lifespan first creates a shutdown cycle. Begin the
234
+ idempotent preview cleanup on a helper thread, which closes every
235
+ session event stream and lets Uvicorn proceed normally.
236
+ """
237
+
238
+ def handle_exit(self, sig, frame) -> None:
239
+ if not self.should_exit and not shutdown_started.is_set():
240
+ shutdown_started.set()
241
+ threading.Thread(
242
+ target=service.shutdown,
243
+ name="bladeworks-preview-shutdown",
244
+ daemon=True,
245
+ ).start()
246
+ super().handle_exit(sig, frame)
247
+
248
+ server = ForegroundServer(
249
+ uvicorn.Config(
250
+ app,
251
+ host=config.host,
252
+ port=selected_port,
253
+ log_level=config.log_level,
254
+ workers=1,
255
+ reload=False,
256
+ # Infinite SSE responses must not prevent a foreground signal
257
+ # from reaching lifespan cleanup. Uvicorn cancels any client
258
+ # connection still open after this bounded grace period, then
259
+ # the application closes its preview sessions and WebRTC peers.
260
+ timeout_graceful_shutdown=5,
261
+ )
262
+ )
263
+ try:
264
+ server.run(sockets=[listener])
265
+ except KeyboardInterrupt:
266
+ # Some event-loop implementations re-raise after Uvicorn has
267
+ # completed lifespan shutdown. A foreground Ctrl-C is a normal
268
+ # stop, not a Bladeworks error or traceback.
269
+ pass
270
+ return 0
271
+ finally:
272
+ # Lifespan normally owns this cleanup. Repeat it here because a second
273
+ # signal or event-loop cancellation can interrupt ASGI shutdown. Both
274
+ # services are idempotent, so the fallback cannot double-close work.
275
+ if service is not None:
276
+ service.shutdown()
277
+ if renders is not None:
278
+ renders.shutdown()
279
+ if listener is not None:
280
+ listener.close()
281
+ shutil.rmtree(instance_directory, ignore_errors=True)
282
+
283
+
284
+ _CHROME_APP_BINARIES = (
285
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
286
+ Path("/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"),
287
+ Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
288
+ Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
289
+ Path("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
290
+ )
291
+
292
+
293
+ def _launch_chrome_app_window(url: str) -> bool:
294
+ """Open Studio as a Chromium app window so Cmd-N is not a New Window chord.
295
+
296
+ A normal Chrome tab never delivers a cancelable Cmd-N to the page. App
297
+ windows only reserve Close and Exit, so Studio's capture-phase keydown
298
+ can own New Project.
299
+
300
+ Main callers: ``_launch_browser`` when ``bladeworks studio`` opens a UI.
301
+ """
302
+
303
+ for binary in _CHROME_APP_BINARIES:
304
+ if not binary.is_file():
305
+ continue
306
+ try:
307
+ subprocess.Popen(
308
+ [str(binary), f"--app={url}"],
309
+ stdout=subprocess.DEVNULL,
310
+ stderr=subprocess.DEVNULL,
311
+ start_new_session=True,
312
+ )
313
+ return True
314
+ except OSError:
315
+ continue
316
+ return False
317
+
318
+
319
+ def _launch_browser(url: str) -> None:
320
+ """Open Studio without putting the runtime token in a warning line."""
321
+
322
+ if _launch_chrome_app_window(url):
323
+ return
324
+ import webbrowser
325
+
326
+ if not webbrowser.open(url):
327
+ print(
328
+ "warning: could not open a browser automatically; use studioUrl from the ready record",
329
+ file=sys.stderr,
330
+ )
331
+
332
+
333
+ def check_health(url: str, *, timeout: float = 2.0) -> int:
334
+ """Check liveness and readiness, printing readiness JSON on success."""
335
+
336
+ base = url.rstrip("/")
337
+ try:
338
+ with urlopen(Request(f"{base}/healthz", method="GET"), timeout=timeout) as response:
339
+ if response.status != 200:
340
+ raise RuntimeError(f"healthz returned HTTP {response.status}")
341
+ with urlopen(Request(f"{base}/readyz", method="GET"), timeout=timeout) as response:
342
+ payload = json.loads(response.read())
343
+ if response.status != 200 or payload.get("ready") is not True:
344
+ raise RuntimeError("server is not ready")
345
+ except (HTTPError, URLError, OSError, RuntimeError, ValueError, json.JSONDecodeError) as error:
346
+ print(f"error: Bladeworks server health check failed: {error}", file=sys.stderr)
347
+ return 1
348
+ print(json.dumps(payload, indent=2, sort_keys=True))
349
+ return 0
@@ -0,0 +1,61 @@
1
+ """Local API authentication and loopback CORS validation.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ runner creates random token
7
+ -> stdout ready record gives it to the launcher
8
+ -> BearerTokenAuth protects preview, render, and mutation routers
9
+
10
+ Cross-origin browser access is opt-in and limited to explicit loopback
11
+ origins. Command-line tools do not send an Origin header and are unaffected.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hmac
17
+ import secrets
18
+ from dataclasses import dataclass
19
+ from urllib.parse import urlsplit
20
+
21
+ from fastapi import Request
22
+
23
+ from .contracts import PreviewAPIError
24
+
25
+
26
+ def new_auth_token() -> str:
27
+ return secrets.token_urlsafe(32)
28
+
29
+
30
+ def validate_loopback_origin(value: str) -> str:
31
+ """Return a normalized explicit HTTP origin or fail loudly."""
32
+
33
+ parsed = urlsplit(value)
34
+ if (
35
+ parsed.scheme not in {"http", "https"}
36
+ or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
37
+ or parsed.username is not None
38
+ or parsed.password is not None
39
+ or parsed.path not in {"", "/"}
40
+ or parsed.query
41
+ or parsed.fragment
42
+ ):
43
+ raise ValueError(f"CORS origin must be an explicit HTTP loopback origin: {value!r}")
44
+ try:
45
+ port = parsed.port
46
+ except ValueError as error:
47
+ raise ValueError(f"CORS origin has an invalid port: {value!r}") from error
48
+ host = f"[{parsed.hostname}]" if parsed.hostname == "::1" else parsed.hostname
49
+ suffix = f":{port}" if port is not None else ""
50
+ return f"{parsed.scheme}://{host}{suffix}"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class BearerTokenAuth:
55
+ token: str
56
+
57
+ def __call__(self, request: Request) -> None:
58
+ supplied = request.headers.get("Authorization", "")
59
+ expected = f"Bearer {self.token}"
60
+ if not hmac.compare_digest(supplied, expected):
61
+ raise PreviewAPIError("unauthorized", "A valid Bladeworks bearer token is required.", status=401)
@@ -0,0 +1,234 @@
1
+ """Session registry and command orchestration for local Bladeworks preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import uuid
7
+ from fractions import Fraction
8
+ from ..tensor.resolution import RenderMode, profile_for_mode
9
+ from .contracts import (
10
+ FrameProducerFactory,
11
+ PreviewAPIError,
12
+ PreviewMediaFactory,
13
+ PreviewAudioProducerFactory,
14
+ SourceDocumentProvider,
15
+ SessionDescription,
16
+ SessionQuality,
17
+ )
18
+ from .media_reporting import missing_media_event_data
19
+ from .session import PreviewSession
20
+
21
+
22
+ class PreviewService:
23
+ """Own preview sessions but not projects, Final Cut libraries, or HTTP."""
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ documents: SourceDocumentProvider,
29
+ producers: FrameProducerFactory,
30
+ raw_media=None,
31
+ media: PreviewMediaFactory | None = None,
32
+ audio: PreviewAudioProducerFactory | None = None,
33
+ ) -> None:
34
+ self.documents = documents
35
+ self.producers = producers
36
+ # ``raw_media`` is the main preview transport (raw frames over a
37
+ # WebSocket). ``media`` is the quarantined WebRTC factory, kept for the
38
+ # legacy ``/sessions`` route but no longer the default path.
39
+ self.raw_media = raw_media
40
+ self.media = media
41
+ self.audio = audio
42
+ self._lock = threading.Lock()
43
+ self._sessions: dict[str, PreviewSession] = {}
44
+
45
+ def _require_audio_support(self, document) -> None:
46
+ has_audible_audio = (
47
+ document.audio is not None
48
+ and any(item.audible for item in document.audio.items)
49
+ )
50
+ if has_audible_audio and self.audio is None:
51
+ raise PreviewAPIError(
52
+ "preview_audio_unavailable",
53
+ "This project has audible timeline audio, but no live audio producer is configured.",
54
+ status=503,
55
+ )
56
+
57
+ def create_raw_session(
58
+ self,
59
+ *,
60
+ source_version: str,
61
+ project_ref: str,
62
+ playhead: Fraction,
63
+ quality: SessionQuality,
64
+ ) -> PreviewSession:
65
+ """Create a session whose media flows as raw frames over a WebSocket.
66
+
67
+ Why this exists:
68
+ The raw-frame transport has no SDP offer/answer handshake — the browser
69
+ just opens a WebSocket after this returns. So unlike ``create_session``
70
+ there is nothing to negotiate and nothing to return but the session
71
+ itself. All session behaviour (seek, paced scan, generation guarding)
72
+ is identical; only the sink differs.
73
+
74
+ Main callers:
75
+ - the ``POST /api/editor/preview/sessions/raw`` route.
76
+ """
77
+
78
+ if self.raw_media is None:
79
+ raise PreviewAPIError(
80
+ "unsupported_construct",
81
+ "The raw-frame preview transport is not enabled on this server.",
82
+ status=422,
83
+ )
84
+ loaded = self.documents.require_current(source_version, project_ref)
85
+ document = loaded.document
86
+ self._require_audio_support(document)
87
+ sink = self.raw_media.open()
88
+ session_id = f"preview-{uuid.uuid4().hex}"
89
+ try:
90
+ session = PreviewSession(
91
+ session_id=session_id,
92
+ source_version=loaded.version,
93
+ project_ref=loaded.project_ref,
94
+ document=document,
95
+ quality=quality,
96
+ producer_factory=self.producers,
97
+ media_sink=sink,
98
+ audio_factory=self.audio,
99
+ playhead=playhead,
100
+ )
101
+ except BaseException:
102
+ sink.close()
103
+ raise
104
+ with self._lock:
105
+ self._sessions[session_id] = session
106
+ self._publish_missing_media(session)
107
+ return session
108
+
109
+ def create_session(
110
+ self,
111
+ *,
112
+ source_version: str,
113
+ project_ref: str,
114
+ playhead: Fraction,
115
+ offer: SessionDescription,
116
+ quality: SessionQuality,
117
+ ) -> tuple[PreviewSession, SessionDescription]:
118
+ if self.media is None:
119
+ raise PreviewAPIError(
120
+ "unsupported_construct",
121
+ "The WebRTC preview transport is not enabled on this server.",
122
+ status=422,
123
+ )
124
+ loaded = self.documents.require_current(source_version, project_ref)
125
+ document = loaded.document
126
+ self._require_audio_support(document)
127
+ answer, sink = self.media.negotiate(offer)
128
+ session_id = f"preview-{uuid.uuid4().hex}"
129
+ try:
130
+ session = PreviewSession(
131
+ session_id=session_id,
132
+ source_version=loaded.version,
133
+ project_ref=loaded.project_ref,
134
+ document=document,
135
+ quality=quality,
136
+ producer_factory=self.producers,
137
+ media_sink=sink,
138
+ audio_factory=self.audio,
139
+ playhead=playhead,
140
+ )
141
+ except BaseException:
142
+ sink.close()
143
+ raise
144
+ with self._lock:
145
+ self._sessions[session_id] = session
146
+ self._publish_missing_media(session)
147
+ return session, answer
148
+
149
+ def session(self, session_id: str) -> PreviewSession:
150
+ with self._lock:
151
+ session = self._sessions.get(session_id)
152
+ if session is None:
153
+ raise PreviewAPIError(
154
+ "preview_not_found",
155
+ f"Preview session {session_id!r} does not exist.",
156
+ status=404,
157
+ )
158
+ return session
159
+
160
+ def sync(
161
+ self,
162
+ session_id: str,
163
+ *,
164
+ source_version: str,
165
+ project_ref: str,
166
+ playhead: Fraction,
167
+ quality: SessionQuality | None = None,
168
+ ) -> dict[str, object]:
169
+ loaded = self.documents.require_current(source_version, project_ref)
170
+ document = loaded.document
171
+ self._require_audio_support(document)
172
+ session = self.session(session_id)
173
+ result = session.sync(
174
+ source_version=loaded.version,
175
+ project_ref=loaded.project_ref,
176
+ document=document,
177
+ playhead=playhead,
178
+ quality=quality or session.quality,
179
+ )
180
+ self._publish_missing_media(session)
181
+ return result
182
+
183
+ @staticmethod
184
+ def _publish_missing_media(session: PreviewSession) -> None:
185
+ """Publish degraded media state after a source is pinned.
186
+
187
+ Main callers:
188
+ - preview creation, after the session owns its initial document.
189
+ - source sync, after replacement succeeds transactionally.
190
+ """
191
+
192
+ payload = missing_media_event_data(
193
+ session.document,
194
+ source_version=session.source_version,
195
+ )
196
+ if payload is not None:
197
+ payload["projectRef"] = session.project_ref
198
+ session.events.publish("missing_media", payload)
199
+
200
+ def close(self, session_id: str) -> None:
201
+ with self._lock:
202
+ session = self._sessions.pop(session_id, None)
203
+ if session is not None:
204
+ session.close()
205
+
206
+ @property
207
+ def active_count(self) -> int:
208
+ with self._lock:
209
+ return len(self._sessions)
210
+
211
+ def shutdown(self) -> None:
212
+ """Close every session and refuse no cleanup work during app shutdown."""
213
+
214
+ with self._lock:
215
+ sessions = tuple(self._sessions.values())
216
+ self._sessions.clear()
217
+ for session in sessions:
218
+ session.close()
219
+
220
+
221
+ def validate_quality(resolution: str | None, fallbacks: list[str] | None = None) -> SessionQuality:
222
+ """Validate the one fixed preview tier selected by the user."""
223
+
224
+ if fallbacks:
225
+ raise PreviewAPIError(
226
+ "invalid_resolution",
227
+ "Automatic preview fallback is disabled. Select one fixed resolution.",
228
+ status=400,
229
+ )
230
+ try:
231
+ selected = profile_for_mode(RenderMode.SCAN, resolution)
232
+ except ValueError as error:
233
+ raise PreviewAPIError("invalid_resolution", str(error), status=400) from error
234
+ return SessionQuality(selected)