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,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