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.
- bladeworks/__init__.py +56 -0
- bladeworks/__main__.py +4 -0
- bladeworks/cli.py +728 -0
- bladeworks/core/__init__.py +34 -0
- bladeworks/core/animation.py +893 -0
- bladeworks/core/animation_execution.py +817 -0
- bladeworks/core/audio_enhancements.py +673 -0
- bladeworks/core/audio_execution.py +1848 -0
- bladeworks/core/audio_ir.py +1971 -0
- bladeworks/core/basic_effects.py +168 -0
- bladeworks/core/bindings.py +196 -0
- bladeworks/core/capabilities.py +257 -0
- bladeworks/core/cohort_effects.py +702 -0
- bladeworks/core/color.py +324 -0
- bladeworks/core/compiler.py +3142 -0
- bladeworks/core/composition_diagnostics.py +629 -0
- bladeworks/core/composition_ir.py +3154 -0
- bladeworks/core/composition_planning.py +708 -0
- bladeworks/core/composition_shadow.py +687 -0
- bladeworks/core/compositor.py +1186 -0
- bladeworks/core/effect_parameter_semantics.py +165 -0
- bladeworks/core/effect_parameters.py +193 -0
- bladeworks/core/errors.py +21 -0
- bladeworks/core/filter_text.py +48 -0
- bladeworks/core/geometry.py +1711 -0
- bladeworks/core/group_surface.py +664 -0
- bladeworks/core/keyer.py +280 -0
- bladeworks/core/masks.py +453 -0
- bladeworks/core/missing_media.py +130 -0
- bladeworks/core/model.py +985 -0
- bladeworks/core/multicam_execution.py +496 -0
- bladeworks/core/output_color.py +52 -0
- bladeworks/core/parser.py +1257 -0
- bladeworks/core/pixel_domains.py +433 -0
- bladeworks/core/render_profile.py +311 -0
- bladeworks/core/render_sources.py +475 -0
- bladeworks/core/report.py +118 -0
- bladeworks/core/retime.py +967 -0
- bladeworks/core/retime_execution.py +1515 -0
- bladeworks/core/spatial_intrinsics.py +1749 -0
- bladeworks/core/story_containers.py +524 -0
- bladeworks/core/story_ir.py +1042 -0
- bladeworks/core/text.py +1032 -0
- bladeworks/core/text_templates.py +934 -0
- bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
- bladeworks/effects/__init__.py +1 -0
- bladeworks/effects/contract.py +162 -0
- bladeworks/examples/__init__.py +80 -0
- bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
- bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
- bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
- bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/executor.py +293 -0
- bladeworks/preview/__init__.py +11 -0
- bladeworks/preview/application.py +84 -0
- bladeworks/preview/audio.py +104 -0
- bladeworks/preview/capabilities.py +457 -0
- bladeworks/preview/contracts.py +157 -0
- bladeworks/preview/events.py +55 -0
- bladeworks/preview/export.py +57 -0
- bladeworks/preview/history.py +197 -0
- bladeworks/preview/main.py +19 -0
- bladeworks/preview/media.py +23 -0
- bladeworks/preview/media_library.py +381 -0
- bladeworks/preview/media_reporting.py +55 -0
- bladeworks/preview/media_routes.py +155 -0
- bladeworks/preview/media_visuals.py +225 -0
- bladeworks/preview/producer.py +211 -0
- bladeworks/preview/provider.py +74 -0
- bladeworks/preview/rawframe.py +218 -0
- bladeworks/preview/render_jobs.py +311 -0
- bladeworks/preview/routes.py +375 -0
- bladeworks/preview/runner.py +349 -0
- bladeworks/preview/security.py +61 -0
- bladeworks/preview/service.py +234 -0
- bladeworks/preview/session.py +457 -0
- bladeworks/preview/source.py +603 -0
- bladeworks/preview/source_routes.py +151 -0
- bladeworks/preview/studio.py +77 -0
- bladeworks/preview/webrtc.py +295 -0
- bladeworks/spatial_luts/manifest.json +17 -0
- bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/studio_static/__init__.py +1 -0
- bladeworks/studio_static/app.js +6519 -0
- bladeworks/studio_static/build-id.js +1 -0
- bladeworks/studio_static/capability-ui.js +68 -0
- bladeworks/studio_static/clip-state.js +335 -0
- bladeworks/studio_static/fcpxml.js +3313 -0
- bladeworks/studio_static/fixtures.js +393 -0
- bladeworks/studio_static/history.js +50 -0
- bladeworks/studio_static/index.html +24 -0
- bladeworks/studio_static/magnetic-timeline.js +1194 -0
- bladeworks/studio_static/mask-ui.js +189 -0
- bladeworks/studio_static/media-visuals.js +224 -0
- bladeworks/studio_static/mock-capabilities.js +409 -0
- bladeworks/studio_static/retime-ui.js +151 -0
- bladeworks/studio_static/runtime.js +1652 -0
- bladeworks/studio_static/scope-ui.js +150 -0
- bladeworks/studio_static/styles.css +1558 -0
- bladeworks/studio_static/templates/catalog.js +77 -0
- bladeworks/studio_static/templates/icons.js +84 -0
- bladeworks/studio_static/templates/inspector.js +553 -0
- bladeworks/studio_static/templates/library.js +61 -0
- bladeworks/studio_static/templates/media.js +185 -0
- bladeworks/studio_static/templates/shared.js +43 -0
- bladeworks/studio_static/templates/shell.js +94 -0
- bladeworks/studio_static/templates/timeline.js +377 -0
- bladeworks/studio_static/templates/topbar.js +83 -0
- bladeworks/studio_static/templates/types.js +7 -0
- bladeworks/studio_static/templates/viewer.js +174 -0
- bladeworks/studio_static/templates.js +31 -0
- bladeworks/studio_static/types.js +18 -0
- bladeworks/studio_static/ui.js +97 -0
- bladeworks/studio_static/viewer-geometry.js +55 -0
- bladeworks/tensor/__init__.py +131 -0
- bladeworks/tensor/audio_delivery.py +280 -0
- bladeworks/tensor/audio_pyav.py +360 -0
- bladeworks/tensor/blend.py +217 -0
- bladeworks/tensor/color.py +53 -0
- bladeworks/tensor/composite.py +33 -0
- bladeworks/tensor/decode.py +1309 -0
- bladeworks/tensor/decode_policy.py +363 -0
- bladeworks/tensor/effects.py +322 -0
- bladeworks/tensor/encode.py +614 -0
- bladeworks/tensor/errors.py +32 -0
- bladeworks/tensor/expr.py +775 -0
- bladeworks/tensor/fx_basic.py +946 -0
- bladeworks/tensor/fx_branched.py +640 -0
- bladeworks/tensor/fx_cohort.py +404 -0
- bladeworks/tensor/fx_color.py +851 -0
- bladeworks/tensor/fx_keyer.py +140 -0
- bladeworks/tensor/fx_mask.py +352 -0
- bladeworks/tensor/fx_warp.py +337 -0
- bladeworks/tensor/hdr.py +122 -0
- bladeworks/tensor/pipeline.py +401 -0
- bladeworks/tensor/plan.py +2231 -0
- bladeworks/tensor/renderer.py +1199 -0
- bladeworks/tensor/requirements.txt +28 -0
- bladeworks/tensor/resolution.py +246 -0
- bladeworks/tensor/sampler.py +598 -0
- bladeworks/tensor/support.py +169 -0
- bladeworks/tensor/swscale_fixedpoint.py +181 -0
- bladeworks/tensor/tr_equirect.py +420 -0
- bladeworks/tensor/tr_handlers.py +443 -0
- bladeworks/tensor/tr_phase5.py +697 -0
- bladeworks/tensor/transitions.py +424 -0
- bladeworks/transitions/__init__.py +10 -0
- bladeworks/transitions/carryovers.py +299 -0
- bladeworks/transitions/cohort.py +981 -0
- bladeworks/transitions/contract.py +490 -0
- bladeworks/transitions/equirectangular.py +915 -0
- bladeworks/transitions/light_deco.py +141 -0
- bladeworks/transitions/panel_motion.py +495 -0
- bladeworks/transitions/stock.py +270 -0
- bladeworks-0.1.0.dist-info/METADATA +66 -0
- bladeworks-0.1.0.dist-info/RECORD +178 -0
- bladeworks-0.1.0.dist-info/WHEEL +5 -0
- bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|