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,375 @@
|
|
|
1
|
+
"""FastAPI adapter for source-version preview commands, renders, and SSE.
|
|
2
|
+
|
|
3
|
+
Renderer work runs in worker threads so seek, scan shutdown, and export setup
|
|
4
|
+
cannot freeze the HTTP event loop. Source and media routers are mounted by the
|
|
5
|
+
production assembly and remain separate from this transport module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from typing import Any, Callable, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, FastAPI, Header, Request, WebSocket, WebSocketDisconnect
|
|
16
|
+
from fastapi.exceptions import RequestValidationError
|
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
18
|
+
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
|
19
|
+
|
|
20
|
+
from ..tensor.errors import TensorRenderError, TensorRenderUnsupported
|
|
21
|
+
from .contracts import PreviewAPIError, SessionDescription, SessionQuality, fraction_from_seconds
|
|
22
|
+
from .rawframe import CLOSED, EMPTY, RawFrameMediaSink, RawFramePayload
|
|
23
|
+
from .render_jobs import RenderJobService
|
|
24
|
+
from .security import BearerTokenAuth
|
|
25
|
+
from .service import PreviewService, validate_quality
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# The browser cannot set an Authorization header on a WebSocket handshake, so
|
|
29
|
+
# the bearer token rides in the ``Sec-WebSocket-Protocol`` list alongside this
|
|
30
|
+
# marker subprotocol. token_urlsafe values are valid subprotocol tokens.
|
|
31
|
+
_WS_SUBPROTOCOL = "bladeworks-preview"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _required(payload: dict[str, Any], name: str, expected_type: type) -> Any:
|
|
35
|
+
value = payload.get(name)
|
|
36
|
+
if isinstance(value, bool) or not isinstance(value, expected_type):
|
|
37
|
+
raise PreviewAPIError("invalid_request", f"{name} is required and must be {expected_type.__name__}", status=400)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _offer(payload: dict[str, Any]) -> SessionDescription:
|
|
42
|
+
value = payload.get("offer")
|
|
43
|
+
if not isinstance(value, dict):
|
|
44
|
+
raise PreviewAPIError("invalid_request", "offer is required", status=400)
|
|
45
|
+
offer_type = _required(value, "type", str)
|
|
46
|
+
sdp = _required(value, "sdp", str)
|
|
47
|
+
if offer_type != "offer":
|
|
48
|
+
raise PreviewAPIError("invalid_request", "offer.type must be 'offer'", status=400)
|
|
49
|
+
return SessionDescription(type=offer_type, sdp=sdp)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _quality(payload: dict[str, Any]) -> SessionQuality:
|
|
53
|
+
"""Parse one fixed preview quality without accepting obsolete fallback fields."""
|
|
54
|
+
|
|
55
|
+
value = payload.get("quality")
|
|
56
|
+
if value is not None and not isinstance(value, dict):
|
|
57
|
+
raise PreviewAPIError("invalid_request", "quality must be an object", status=400)
|
|
58
|
+
value = value or {}
|
|
59
|
+
unknown = sorted(set(value) - {"resolution"})
|
|
60
|
+
if unknown:
|
|
61
|
+
raise PreviewAPIError(
|
|
62
|
+
"invalid_request",
|
|
63
|
+
f"Unsupported quality fields: {', '.join(unknown)}",
|
|
64
|
+
status=400,
|
|
65
|
+
)
|
|
66
|
+
resolution = value.get("resolution")
|
|
67
|
+
if resolution is not None and not isinstance(resolution, str):
|
|
68
|
+
raise PreviewAPIError("invalid_resolution", "quality.resolution must be a string", status=400)
|
|
69
|
+
return validate_quality(resolution)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_app(
|
|
73
|
+
service: PreviewService,
|
|
74
|
+
*,
|
|
75
|
+
renders: RenderJobService | None = None,
|
|
76
|
+
auth_token: str | None = None,
|
|
77
|
+
allowed_origins: tuple[str, ...] = (),
|
|
78
|
+
readiness: Callable[[], Mapping[str, object]] | None = None,
|
|
79
|
+
protected_routers: tuple[APIRouter, ...] = (),
|
|
80
|
+
) -> FastAPI:
|
|
81
|
+
"""Create the transport app around already-constructed service objects.
|
|
82
|
+
|
|
83
|
+
Main callers:
|
|
84
|
+
- ``application.create_local_preview_app`` for the production server.
|
|
85
|
+
- focused tests that inject fake render, media, and source adapters.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def lifespan(_app: FastAPI):
|
|
90
|
+
yield
|
|
91
|
+
await asyncio.to_thread(service.shutdown)
|
|
92
|
+
if renders is not None:
|
|
93
|
+
await asyncio.to_thread(renders.shutdown)
|
|
94
|
+
|
|
95
|
+
app = FastAPI(title="Bladeworks Local FCPXML API", version="1", lifespan=lifespan)
|
|
96
|
+
app.state.preview_service = service
|
|
97
|
+
app.state.render_service = renders
|
|
98
|
+
if allowed_origins:
|
|
99
|
+
app.add_middleware(
|
|
100
|
+
CORSMiddleware,
|
|
101
|
+
allow_origins=list(allowed_origins),
|
|
102
|
+
allow_credentials=False,
|
|
103
|
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
104
|
+
allow_headers=[
|
|
105
|
+
"Accept",
|
|
106
|
+
"Authorization",
|
|
107
|
+
"Content-Type",
|
|
108
|
+
"If-Match",
|
|
109
|
+
"Last-Event-ID",
|
|
110
|
+
"X-Bladeworks-Filename",
|
|
111
|
+
],
|
|
112
|
+
expose_headers=[
|
|
113
|
+
"ETag",
|
|
114
|
+
"X-Bladeworks-Compile-Status",
|
|
115
|
+
"X-Bladeworks-Disk-Version",
|
|
116
|
+
"X-Bladeworks-Editor-Profile",
|
|
117
|
+
"X-Bladeworks-History-Index",
|
|
118
|
+
"X-Bladeworks-History-Length",
|
|
119
|
+
"X-Bladeworks-Loaded-Version",
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
auth = BearerTokenAuth(auth_token) if auth_token is not None else None
|
|
124
|
+
|
|
125
|
+
def protected() -> tuple[Any, ...]:
|
|
126
|
+
return (Depends(auth),) if auth is not None else ()
|
|
127
|
+
|
|
128
|
+
for router in protected_routers:
|
|
129
|
+
app.include_router(router, dependencies=list(protected()))
|
|
130
|
+
|
|
131
|
+
@app.exception_handler(PreviewAPIError)
|
|
132
|
+
async def preview_error(_request: Request, error: PreviewAPIError) -> JSONResponse:
|
|
133
|
+
return JSONResponse(status_code=error.status, content=error.body())
|
|
134
|
+
|
|
135
|
+
@app.exception_handler(RequestValidationError)
|
|
136
|
+
async def request_error(_request: Request, _error: RequestValidationError) -> JSONResponse:
|
|
137
|
+
public = PreviewAPIError("invalid_request", "Request JSON does not match the API contract.", status=400)
|
|
138
|
+
return JSONResponse(status_code=public.status, content=public.body())
|
|
139
|
+
|
|
140
|
+
@app.exception_handler(TensorRenderUnsupported)
|
|
141
|
+
async def unsupported_error(_request: Request, error: TensorRenderUnsupported) -> JSONResponse:
|
|
142
|
+
public = PreviewAPIError("unsupported_construct", str(error), status=422)
|
|
143
|
+
return JSONResponse(status_code=public.status, content=public.body())
|
|
144
|
+
|
|
145
|
+
@app.exception_handler(TensorRenderError)
|
|
146
|
+
async def renderer_error(_request: Request, error: TensorRenderError) -> JSONResponse:
|
|
147
|
+
public = PreviewAPIError("preview_failed", str(error), status=500)
|
|
148
|
+
return JSONResponse(status_code=public.status, content=public.body())
|
|
149
|
+
|
|
150
|
+
@app.exception_handler(Exception)
|
|
151
|
+
async def unexpected_error(_request: Request, _error: Exception) -> JSONResponse:
|
|
152
|
+
public = PreviewAPIError("preview_failed", "The local preview service encountered an unexpected error.", status=500)
|
|
153
|
+
return JSONResponse(status_code=public.status, content=public.body())
|
|
154
|
+
|
|
155
|
+
@app.get("/healthz")
|
|
156
|
+
async def health() -> dict[str, object]:
|
|
157
|
+
return {"ok": True}
|
|
158
|
+
|
|
159
|
+
@app.get("/readyz")
|
|
160
|
+
async def ready() -> JSONResponse:
|
|
161
|
+
payload = dict(readiness()) if readiness is not None else {"ready": True}
|
|
162
|
+
return JSONResponse(status_code=200 if payload.get("ready") is True else 503, content=payload)
|
|
163
|
+
|
|
164
|
+
@app.post("/api/editor/preview/sessions", status_code=201, dependencies=list(protected()))
|
|
165
|
+
async def create_session(payload: dict[str, Any]) -> dict[str, object]:
|
|
166
|
+
source_version = _required(payload, "sourceVersion", str)
|
|
167
|
+
project_ref = _required(payload, "projectRef", str)
|
|
168
|
+
playhead = fraction_from_seconds(payload.get("playhead", 0), field="playhead")
|
|
169
|
+
session, answer = await asyncio.to_thread(
|
|
170
|
+
service.create_session,
|
|
171
|
+
source_version=source_version,
|
|
172
|
+
project_ref=project_ref,
|
|
173
|
+
playhead=playhead,
|
|
174
|
+
offer=_offer(payload),
|
|
175
|
+
quality=_quality(payload),
|
|
176
|
+
)
|
|
177
|
+
return {
|
|
178
|
+
"sessionId": session.session_id,
|
|
179
|
+
"answer": {"type": answer.type, "sdp": answer.sdp},
|
|
180
|
+
"eventsUrl": f"/api/editor/preview/sessions/{session.session_id}/events",
|
|
181
|
+
**session.state_payload(),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@app.post("/api/editor/preview/sessions/raw", status_code=201, dependencies=list(protected()))
|
|
185
|
+
async def create_raw_session(payload: dict[str, Any]) -> dict[str, object]:
|
|
186
|
+
"""Create a raw-frame preview session (the main preview transport).
|
|
187
|
+
|
|
188
|
+
Returns the session id plus the WebSocket URL the browser opens to
|
|
189
|
+
receive uncompressed frames. There is no SDP answer — see
|
|
190
|
+
``PreviewService.create_raw_session``.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
source_version = _required(payload, "sourceVersion", str)
|
|
194
|
+
project_ref = _required(payload, "projectRef", str)
|
|
195
|
+
playhead = fraction_from_seconds(payload.get("playhead", 0), field="playhead")
|
|
196
|
+
session = await asyncio.to_thread(
|
|
197
|
+
service.create_raw_session,
|
|
198
|
+
source_version=source_version,
|
|
199
|
+
project_ref=project_ref,
|
|
200
|
+
playhead=playhead,
|
|
201
|
+
quality=_quality(payload),
|
|
202
|
+
)
|
|
203
|
+
return {
|
|
204
|
+
"sessionId": session.session_id,
|
|
205
|
+
"streamUrl": f"/api/editor/preview/sessions/{session.session_id}/stream",
|
|
206
|
+
"eventsUrl": f"/api/editor/preview/sessions/{session.session_id}/events",
|
|
207
|
+
**session.state_payload(),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@app.websocket("/api/editor/preview/sessions/{session_id}/stream")
|
|
211
|
+
async def stream(websocket: WebSocket, session_id: str) -> None:
|
|
212
|
+
"""Drain one raw-frame session's queue to the browser.
|
|
213
|
+
|
|
214
|
+
Two cooperating tasks: the sender pulls frames off the sink's
|
|
215
|
+
thread-safe queue (through a worker thread so the blocking get never
|
|
216
|
+
stalls the event loop) and writes them as binary WebSocket messages;
|
|
217
|
+
the receiver just watches for the client hanging up. Either one ending
|
|
218
|
+
tears the pair down.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
offered = list(websocket.scope.get("subprotocols", ()))
|
|
222
|
+
if auth is not None and (auth_token is None or auth_token not in offered):
|
|
223
|
+
await websocket.close(code=1008)
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
session = service.session(session_id)
|
|
227
|
+
except PreviewAPIError:
|
|
228
|
+
await websocket.close(code=1011)
|
|
229
|
+
return
|
|
230
|
+
sink = session.media_sink
|
|
231
|
+
if not isinstance(sink, RawFrameMediaSink):
|
|
232
|
+
# Session exists but was created for the WebRTC transport.
|
|
233
|
+
await websocket.close(code=1011)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
accept_kwargs = {"subprotocol": _WS_SUBPROTOCOL} if auth is not None else {}
|
|
237
|
+
await websocket.accept(**accept_kwargs)
|
|
238
|
+
await websocket.send_json(
|
|
239
|
+
{**session.state_payload(), "type": "meta"}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
stop = asyncio.Event()
|
|
243
|
+
|
|
244
|
+
async def receiver() -> None:
|
|
245
|
+
try:
|
|
246
|
+
while not stop.is_set():
|
|
247
|
+
await websocket.receive()
|
|
248
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
249
|
+
pass
|
|
250
|
+
finally:
|
|
251
|
+
stop.set()
|
|
252
|
+
|
|
253
|
+
async def sender() -> None:
|
|
254
|
+
try:
|
|
255
|
+
while not stop.is_set():
|
|
256
|
+
payload = await asyncio.to_thread(sink.get, 0.25)
|
|
257
|
+
if payload is CLOSED:
|
|
258
|
+
break
|
|
259
|
+
if payload is EMPTY:
|
|
260
|
+
continue
|
|
261
|
+
if not isinstance(payload, RawFramePayload) or not sink.is_current(payload):
|
|
262
|
+
continue
|
|
263
|
+
await websocket.send_bytes(payload.data)
|
|
264
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
265
|
+
pass
|
|
266
|
+
finally:
|
|
267
|
+
stop.set()
|
|
268
|
+
|
|
269
|
+
receive_task = asyncio.create_task(receiver())
|
|
270
|
+
send_task = asyncio.create_task(sender())
|
|
271
|
+
try:
|
|
272
|
+
await asyncio.wait({receive_task, send_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
273
|
+
finally:
|
|
274
|
+
stop.set()
|
|
275
|
+
for task in (receive_task, send_task):
|
|
276
|
+
task.cancel()
|
|
277
|
+
await asyncio.gather(receive_task, send_task, return_exceptions=True)
|
|
278
|
+
# The raw-frame transport binds one session to exactly one WebSocket
|
|
279
|
+
# and has no reconnect path: once this socket is gone the session can
|
|
280
|
+
# never be resumed. Tear it down here so a crashed or closed tab does
|
|
281
|
+
# not strand its producer, decoder, and scan thread until the whole
|
|
282
|
+
# server shuts down. ``service.close`` is idempotent (a later DELETE
|
|
283
|
+
# is a no-op) and joins the scan thread, so it runs off the event
|
|
284
|
+
# loop via a worker thread.
|
|
285
|
+
await asyncio.to_thread(service.close, session_id)
|
|
286
|
+
|
|
287
|
+
@app.post("/api/editor/preview/sessions/{session_id}/sync", dependencies=list(protected()))
|
|
288
|
+
async def sync(session_id: str, payload: dict[str, Any]) -> dict[str, object]:
|
|
289
|
+
return await asyncio.to_thread(
|
|
290
|
+
service.sync,
|
|
291
|
+
session_id,
|
|
292
|
+
source_version=_required(payload, "sourceVersion", str),
|
|
293
|
+
project_ref=_required(payload, "projectRef", str),
|
|
294
|
+
playhead=fraction_from_seconds(payload.get("playhead", 0), field="playhead"),
|
|
295
|
+
quality=_quality(payload) if "quality" in payload else None,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
@app.post("/api/editor/preview/sessions/{session_id}/seek", dependencies=list(protected()))
|
|
299
|
+
async def seek(session_id: str, payload: dict[str, Any]) -> dict[str, object]:
|
|
300
|
+
return await asyncio.to_thread(
|
|
301
|
+
service.session(session_id).seek,
|
|
302
|
+
requested_time=fraction_from_seconds(payload.get("time"), field="time"),
|
|
303
|
+
request_id=_required(payload, "requestId", str),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
@app.post("/api/editor/preview/sessions/{session_id}/play", status_code=202, dependencies=list(protected()))
|
|
307
|
+
async def play(session_id: str, payload: Optional[dict[str, Any]] = None) -> dict[str, object]:
|
|
308
|
+
payload = payload or {}
|
|
309
|
+
requested = fraction_from_seconds(payload["time"], field="time") if "time" in payload else None
|
|
310
|
+
return await asyncio.to_thread(service.session(session_id).play, requested_time=requested)
|
|
311
|
+
|
|
312
|
+
@app.post("/api/editor/preview/sessions/{session_id}/pause", dependencies=list(protected()))
|
|
313
|
+
async def pause(session_id: str) -> dict[str, object]:
|
|
314
|
+
return await asyncio.to_thread(service.session(session_id).pause)
|
|
315
|
+
|
|
316
|
+
@app.get("/api/editor/preview/sessions/{session_id}/events", dependencies=list(protected()))
|
|
317
|
+
async def events(
|
|
318
|
+
session_id: str,
|
|
319
|
+
last_event_id: Optional[str] = Header(default=None, alias="Last-Event-ID"),
|
|
320
|
+
) -> StreamingResponse:
|
|
321
|
+
try:
|
|
322
|
+
after_id = int(last_event_id) if last_event_id is not None else 0
|
|
323
|
+
except ValueError as error:
|
|
324
|
+
raise PreviewAPIError("invalid_request", "Last-Event-ID must be an integer", status=400) from error
|
|
325
|
+
session = service.session(session_id)
|
|
326
|
+
|
|
327
|
+
def body():
|
|
328
|
+
for record in session.events.subscribe(after_id=after_id):
|
|
329
|
+
data = json.dumps(record.data, separators=(",", ":"))
|
|
330
|
+
yield f"id: {record.id}\nevent: {record.event}\ndata: {data}\n\n"
|
|
331
|
+
|
|
332
|
+
return StreamingResponse(
|
|
333
|
+
body(),
|
|
334
|
+
media_type="text/event-stream",
|
|
335
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@app.delete("/api/editor/preview/sessions/{session_id}", status_code=204, dependencies=list(protected()))
|
|
339
|
+
async def close(session_id: str) -> Response:
|
|
340
|
+
await asyncio.to_thread(service.close, session_id)
|
|
341
|
+
return Response(status_code=204)
|
|
342
|
+
|
|
343
|
+
if renders is not None:
|
|
344
|
+
|
|
345
|
+
@app.post("/api/editor/render", status_code=202, dependencies=list(protected()))
|
|
346
|
+
async def start_render(payload: dict[str, Any]) -> dict[str, object]:
|
|
347
|
+
job = await asyncio.to_thread(
|
|
348
|
+
renders.start,
|
|
349
|
+
source_version=_required(payload, "sourceVersion", str),
|
|
350
|
+
project_ref=_required(payload, "projectRef", str),
|
|
351
|
+
profile=payload.get("resolution"),
|
|
352
|
+
export_profile=payload.get("profile"),
|
|
353
|
+
)
|
|
354
|
+
return job.payload(status_override="queued")
|
|
355
|
+
|
|
356
|
+
@app.get("/api/editor/renders/{job_id}", dependencies=list(protected()))
|
|
357
|
+
async def get_render(job_id: str) -> dict[str, object]:
|
|
358
|
+
return renders.get(job_id).payload()
|
|
359
|
+
|
|
360
|
+
@app.delete("/api/editor/renders/{job_id}", status_code=202, dependencies=list(protected()))
|
|
361
|
+
async def cancel_render(job_id: str) -> dict[str, object]:
|
|
362
|
+
return renders.cancel(job_id).payload()
|
|
363
|
+
|
|
364
|
+
@app.get("/api/editor/renders/{job_id}/artifact")
|
|
365
|
+
async def render_artifact(job_id: str, token: str | None = None) -> FileResponse:
|
|
366
|
+
job = renders.get_artifact(job_id, token)
|
|
367
|
+
if job.status != "completed":
|
|
368
|
+
raise PreviewAPIError("render_failed", "Render artifact is not complete.", status=409)
|
|
369
|
+
return FileResponse(
|
|
370
|
+
job.output_path,
|
|
371
|
+
media_type=job.export_profile.content_type,
|
|
372
|
+
filename=job.output_path.name,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return app
|