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