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,457 @@
1
+ """One preview session: source-version state, cancellation, seek, and paced scan.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ command thread scan worker
7
+ sync/seek/pause/play sequential Bladeworks frames
8
+ -> generation counter -> paced media writes
9
+ -> cancel prior work -> SSE time/buffering
10
+ both share one renderer lock and media sink
11
+ one renderer lock + one media sink
12
+
13
+ Important rules
14
+ ---------------
15
+ * A generation number guards publication, not just computation. Old work may
16
+ finish, but it can never write a stale frame after a newer command.
17
+ * Bladeworks is single-caller within a session. The renderer lock protects its
18
+ decoder and temporal caches.
19
+ * Preview resolution is fixed by the user's Better Quality / Performance
20
+ selection. Slow graphs buffer instead of silently changing image quality.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ import threading
27
+ import time
28
+ from fractions import Fraction
29
+ from typing import Optional
30
+
31
+ from ..core.model import RenderDocument
32
+ from ..tensor.errors import TensorRenderError
33
+ from ..tensor.renderer import FrameWindow
34
+ from ..tensor.resolution import OutputResolution, ResolutionProfile, resolve_output_resolution
35
+ from .contracts import (
36
+ FrameProducerFactory,
37
+ PreviewAPIError,
38
+ PreviewAudioProducerFactory,
39
+ PreviewMediaSink,
40
+ SessionQuality,
41
+ seconds_json,
42
+ )
43
+ from .events import EventStream
44
+
45
+
46
+ class PreviewSession:
47
+ def __init__(
48
+ self,
49
+ *,
50
+ session_id: str,
51
+ source_version: str,
52
+ project_ref: str,
53
+ document: RenderDocument,
54
+ quality: SessionQuality,
55
+ producer_factory: FrameProducerFactory,
56
+ media_sink: PreviewMediaSink,
57
+ audio_factory: PreviewAudioProducerFactory | None,
58
+ playhead: Fraction,
59
+ ) -> None:
60
+ self.session_id = session_id
61
+ self.source_version = source_version
62
+ self.project_ref = project_ref
63
+ self.document = document
64
+ self.quality = quality
65
+ self.producer_factory = producer_factory
66
+ self.media_sink = media_sink
67
+ self.audio_factory = audio_factory
68
+ self.events = EventStream()
69
+ self._state_lock = threading.RLock()
70
+ self._renderer_lock = threading.Lock()
71
+ self._generation = 0
72
+ self._closed = False
73
+ self._playing = False
74
+ self._scan_thread: Optional[threading.Thread] = None
75
+ self._scan_stop = threading.Event()
76
+ self._buffering_reported = False
77
+ self.selected_profile = quality.resolution
78
+ self.output_resolution = self._resolution(self.selected_profile)
79
+ self.current_frame = self._resolve_frame(playhead)
80
+ self.producer = producer_factory.create(document, self.output_resolution)
81
+ self._publish(
82
+ "ready",
83
+ {
84
+ "resolution": self.selected_profile.value,
85
+ "width": self.output_resolution.width,
86
+ "height": self.output_resolution.height,
87
+ },
88
+ )
89
+
90
+ def _publish(self, event: str, data: dict[str, object]) -> None:
91
+ """Attach the pinned library/Project identity to every SSE event."""
92
+
93
+ self.events.publish(
94
+ event,
95
+ {
96
+ "sourceVersion": self.source_version,
97
+ "projectRef": self.project_ref,
98
+ **data,
99
+ },
100
+ )
101
+
102
+ @property
103
+ def playing(self) -> bool:
104
+ with self._state_lock:
105
+ return self._playing
106
+
107
+ @property
108
+ def current_time(self) -> Fraction:
109
+ with self._state_lock:
110
+ return self.current_frame * self.document.frame_duration
111
+
112
+ def _resolution(self, profile: ResolutionProfile) -> OutputResolution:
113
+ return resolve_output_resolution(self.document.width, self.document.height, profile)
114
+
115
+ def _resolve_frame(self, requested: Fraction) -> int:
116
+ return self._resolve_frame_for_document(self.document, requested)
117
+
118
+ @staticmethod
119
+ def _resolve_frame_for_document(document: RenderDocument, requested: Fraction) -> int:
120
+ """Resolve a public time without consulting mutable session state.
121
+
122
+ Main callers:
123
+ - session creation through ``_resolve_frame``;
124
+ - ``sync`` before it stops or replaces the current source version.
125
+
126
+ Why this exists:
127
+ A bad playhead for a new source version must fail before any producer
128
+ or session state changes. That keeps replacement transactional.
129
+ """
130
+
131
+ if document.duration == 0:
132
+ if requested != 0:
133
+ raise PreviewAPIError(
134
+ "time_out_of_range",
135
+ "time 0 is the only valid playhead on an empty timeline",
136
+ status=400,
137
+ )
138
+ return 0
139
+ if requested < 0 or requested >= document.duration:
140
+ raise PreviewAPIError(
141
+ "time_out_of_range",
142
+ f"time {float(requested)} is outside [0, {float(document.duration)})",
143
+ status=400,
144
+ )
145
+ frames = requested / document.frame_duration
146
+ frame = (2 * frames.numerator + frames.denominator) // (2 * frames.denominator)
147
+ return min(frame, document.frame_count - 1)
148
+
149
+ def _superseded(self, generation: int) -> bool:
150
+ with self._state_lock:
151
+ return self._closed or self._generation != generation
152
+
153
+ def _scan_cancelled(self, generation: int) -> bool:
154
+ return self._superseded(generation) or self._scan_stop.is_set()
155
+
156
+ def _stop_scan(self) -> None:
157
+ with self._state_lock:
158
+ thread = self._scan_thread
159
+ self._scan_stop.set()
160
+ self._generation += 1
161
+ self._playing = False
162
+ if thread is not None and thread is not threading.current_thread():
163
+ thread.join()
164
+ with self._state_lock:
165
+ if self._scan_thread is thread:
166
+ self._scan_thread = None
167
+ # The scan worker is now guaranteed stopped, so nothing else is writing
168
+ # to the sink. Drop whatever the old generation already enqueued before
169
+ # the caller (seek/play/sync/pause) publishes the new still or playback
170
+ # stream, so the client never sees stale frames or audio from the
171
+ # superseded generation. Every control boundary routes through here.
172
+ self.media_sink.flush()
173
+
174
+ def sync(
175
+ self,
176
+ *,
177
+ source_version: str,
178
+ project_ref: str,
179
+ document: RenderDocument,
180
+ playhead: Fraction,
181
+ quality: SessionQuality | None = None,
182
+ ) -> dict[str, object]:
183
+ """Atomically replace the compiled source after old work is stopped."""
184
+
185
+ new_frame = self._resolve_frame_for_document(document, playhead)
186
+ selected_quality = quality or self.quality
187
+ new_resolution = resolve_output_resolution(document.width, document.height, selected_quality.resolution)
188
+ self._stop_scan()
189
+ with self._renderer_lock:
190
+ with self._state_lock:
191
+ if self._closed:
192
+ raise PreviewAPIError("preview_not_found", "Preview session is closed.", status=404)
193
+ self._generation += 1
194
+ new_producer = self.producer_factory.create(document, new_resolution)
195
+ old_producer = self.producer
196
+ with self._state_lock:
197
+ self.source_version = source_version
198
+ self.project_ref = project_ref
199
+ self.document = document
200
+ self.quality = selected_quality
201
+ self.selected_profile = selected_quality.resolution
202
+ self.output_resolution = new_resolution
203
+ self.producer = new_producer
204
+ self.current_frame = new_frame
205
+ old_producer.close()
206
+ return self.state_payload()
207
+
208
+ def seek(self, *, requested_time: Fraction, request_id: str) -> dict[str, object]:
209
+ self._stop_scan()
210
+ with self._state_lock:
211
+ if self._closed:
212
+ raise PreviewAPIError("preview_not_found", "Preview session is closed.", status=404)
213
+ self._generation += 1
214
+ generation = self._generation
215
+ frame_number = self._resolve_frame(requested_time)
216
+ with self._renderer_lock:
217
+ try:
218
+ frame = self.producer.seek(
219
+ frame_number,
220
+ is_cancelled=lambda: self._superseded(generation),
221
+ )
222
+ except TensorRenderError as error:
223
+ if self._superseded(generation):
224
+ raise PreviewAPIError(
225
+ "seek_superseded",
226
+ f"Seek {request_id} was superseded by a newer command.",
227
+ status=409,
228
+ retryable=True,
229
+ ) from error
230
+ raise
231
+ with self._state_lock:
232
+ if self._superseded(generation):
233
+ raise PreviewAPIError(
234
+ "seek_superseded",
235
+ f"Seek {request_id} was superseded by a newer command.",
236
+ status=409,
237
+ retryable=True,
238
+ )
239
+ self.media_sink.write_still(frame)
240
+ self.current_frame = frame.frame
241
+ self._publish("time", {"time": seconds_json(frame.time), "frame": frame.frame})
242
+ return {
243
+ "sourceVersion": self.source_version,
244
+ "projectRef": self.project_ref,
245
+ "requestId": request_id,
246
+ "requestedTime": seconds_json(requested_time),
247
+ "actualTime": seconds_json(frame.time),
248
+ "frame": frame.frame,
249
+ "resolution": self.selected_profile.value,
250
+ "width": frame.width,
251
+ "height": frame.height,
252
+ }
253
+
254
+ def play(self, *, requested_time: Optional[Fraction]) -> dict[str, object]:
255
+ self._stop_scan()
256
+ with self._state_lock:
257
+ if self._closed:
258
+ raise PreviewAPIError("preview_not_found", "Preview session is closed.", status=404)
259
+ if requested_time is not None:
260
+ self.current_frame = self._resolve_frame(requested_time)
261
+ start_frame = self.current_frame
262
+ self._scan_stop = threading.Event()
263
+ self._generation += 1
264
+ generation = self._generation
265
+ self._playing = True
266
+ self._buffering_reported = False
267
+ thread = threading.Thread(
268
+ target=self._scan_loop,
269
+ args=(start_frame, generation),
270
+ name=f"tensor-preview:{self.session_id}",
271
+ daemon=True,
272
+ )
273
+ self._scan_thread = thread
274
+ self._publish("playing", {"playing": True})
275
+ thread.start()
276
+ return {
277
+ "sourceVersion": self.source_version,
278
+ "projectRef": self.project_ref,
279
+ "playing": True,
280
+ "startTime": seconds_json(start_frame * self.document.frame_duration),
281
+ "startFrame": start_frame,
282
+ "resolution": self.selected_profile.value,
283
+ }
284
+
285
+ def _scan_loop(self, start_frame: int, generation: int) -> None:
286
+ wall_origin = time.monotonic()
287
+ clock_lock = threading.Lock()
288
+ emitted = 0
289
+ next_frame = start_frame
290
+ time_event_stride = max(
291
+ 1,
292
+ math.ceil(float(1 / self.document.frame_duration) / 30.0),
293
+ )
294
+ audio_producer = self.audio_factory.create(self.document) if self.audio_factory is not None else None
295
+ audio_thread: Optional[threading.Thread] = None
296
+
297
+ def playback_origin() -> float:
298
+ with clock_lock:
299
+ return wall_origin
300
+
301
+ def reset_playback_origin(value: float) -> None:
302
+ nonlocal wall_origin
303
+ with clock_lock:
304
+ wall_origin = value
305
+
306
+ def wait_for_audio_slot(frame_time: Fraction) -> bool:
307
+ """Pace PCM on the video clock and cap its lead during render lag.
308
+
309
+ FFmpeg can produce PCM much faster than realtime. Without this
310
+ gate aiortc would receive future audio immediately while video is
311
+ deliberately wall-clock paced. The 300 ms lead permits a small
312
+ media buffer but makes audio stall when composition falls behind.
313
+ """
314
+
315
+ start_time = start_frame * self.document.frame_duration
316
+ while not self._scan_cancelled(generation):
317
+ target_wall = playback_origin() + float(frame_time - start_time)
318
+ with self._state_lock:
319
+ published_video_time = self.current_frame * self.document.frame_duration
320
+ wall_delay = target_wall - time.monotonic()
321
+ within_video_buffer = frame_time <= published_video_time + Fraction(3, 10)
322
+ if wall_delay <= 0 and within_video_buffer:
323
+ return True
324
+ wait_seconds = min(0.02, max(0.001, wall_delay))
325
+ if self._scan_stop.wait(wait_seconds):
326
+ return False
327
+ return False
328
+
329
+ def pump_audio() -> None:
330
+ assert audio_producer is not None
331
+ try:
332
+ start_time = start_frame * self.document.frame_duration
333
+ for audio_frame in audio_producer.frames(
334
+ start_time,
335
+ is_cancelled=lambda: self._scan_cancelled(generation),
336
+ ):
337
+ if self._scan_cancelled(generation):
338
+ return
339
+ if not wait_for_audio_slot(audio_frame.time):
340
+ return
341
+ self.media_sink.write_audio(audio_frame)
342
+ except Exception as error: # noqa: BLE001 - live failure is an SSE state
343
+ if not self._scan_cancelled(generation):
344
+ self._publish(
345
+ "error",
346
+ {"code": "preview_failed", "message": str(error), "retryable": False},
347
+ )
348
+ self._scan_stop.set()
349
+
350
+ if audio_producer is not None:
351
+ audio_thread = threading.Thread(
352
+ target=pump_audio,
353
+ name=f"tensor-preview-audio:{self.session_id}",
354
+ daemon=True,
355
+ )
356
+ audio_thread.start()
357
+ try:
358
+ while next_frame < self.document.frame_count and not self._scan_cancelled(generation):
359
+ with self._renderer_lock:
360
+ stream = self.producer.frames(
361
+ FrameWindow(next_frame, self.document.frame_count),
362
+ is_cancelled=lambda: self._scan_cancelled(generation),
363
+ )
364
+ for frame in stream:
365
+ if self._scan_cancelled(generation):
366
+ return
367
+ target_wall = playback_origin() + float(emitted * self.document.frame_duration)
368
+ delay = target_wall - time.monotonic()
369
+ if delay > 0:
370
+ time.sleep(delay)
371
+ if self._scan_cancelled(generation):
372
+ return
373
+ self.media_sink.write_video(frame)
374
+ with self._state_lock:
375
+ self.current_frame = frame.frame
376
+ if emitted % time_event_stride == 0:
377
+ self._publish(
378
+ "time",
379
+ {"time": seconds_json(frame.time), "frame": frame.frame},
380
+ )
381
+ emitted += 1
382
+ next_frame = frame.frame + 1
383
+ lag = time.monotonic() - target_wall
384
+ if lag >= 1.0:
385
+ if not self._buffering_reported:
386
+ self._buffering_reported = True
387
+ elapsed = max(0.000001, time.monotonic() - playback_origin())
388
+ render_rate = float(emitted * self.document.frame_duration) / elapsed
389
+ self._publish(
390
+ "buffering",
391
+ {
392
+ "bufferedSeconds": 0.0,
393
+ "renderRate": round(render_rate, 3),
394
+ },
395
+ )
396
+ break
397
+ if not self._scan_cancelled(generation) and next_frame >= self.document.frame_count:
398
+ self._publish("ended", {"time": seconds_json(self.document.duration)})
399
+ except TensorRenderError:
400
+ if not self._scan_cancelled(generation):
401
+ self._publish(
402
+ "error",
403
+ {"code": "preview_failed", "message": "Bladeworks scan failed.", "retryable": False},
404
+ )
405
+ except Exception as error: # noqa: BLE001 - session error must reach SSE
406
+ if not self._scan_cancelled(generation):
407
+ self._publish(
408
+ "error",
409
+ {"code": "preview_failed", "message": str(error), "retryable": False},
410
+ )
411
+ finally:
412
+ if audio_producer is not None:
413
+ audio_producer.close()
414
+ if audio_thread is not None and audio_thread is not threading.current_thread():
415
+ audio_thread.join(timeout=3.0)
416
+ with self._state_lock:
417
+ if self._generation == generation:
418
+ self._playing = False
419
+ self._scan_thread = None
420
+ self._publish("playing", {"playing": False})
421
+
422
+ def pause(self) -> dict[str, object]:
423
+ was_playing = self.playing
424
+ self._stop_scan()
425
+ if was_playing:
426
+ self._publish("playing", {"playing": False})
427
+ return {
428
+ "sourceVersion": self.source_version,
429
+ "projectRef": self.project_ref,
430
+ "playing": False,
431
+ "time": seconds_json(self.current_time),
432
+ "frame": self.current_frame,
433
+ }
434
+
435
+ def state_payload(self) -> dict[str, object]:
436
+ return {
437
+ "sourceVersion": self.source_version,
438
+ "projectRef": self.project_ref,
439
+ "playhead": seconds_json(self.current_time),
440
+ "selectedResolution": self.selected_profile.value,
441
+ "width": self.output_resolution.width,
442
+ "height": self.output_resolution.height,
443
+ }
444
+
445
+ def close(self) -> None:
446
+ with self._state_lock:
447
+ if self._closed:
448
+ return
449
+ self._closed = True
450
+ # Wake an open SSE response before waiting on renderer, decoder, or
451
+ # WebRTC cleanup. This lets Uvicorn finish the client connection while
452
+ # the remaining session resources close in parallel during SIGINT.
453
+ self.events.close()
454
+ self._stop_scan()
455
+ with self._renderer_lock:
456
+ self.producer.close()
457
+ self.media_sink.close()